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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

All notable changes to this project will be documented in this file.

## [0.6.0] - 2026-05-09

### Added

- `lucas ai usage`, `parse-expenses`, `parse-expenses-image`, `insights`, and `chat-message` commands for the current LucasApp AI endpoints.

### Changed

- Public plan copy now exposes only `FREE` and `PREMIUM`; Premium is `$4/month` with unlimited accounts, unlimited subscriptions, and AI limits of 50/day, 300/week, and 1000/month.
- Receipt image parsing now documents and enforces a maximum of 10 images per request.
- Backend limit errors (`AI_PLAN_REQUIRED`, `AI_LIMIT_REACHED`, `SUBSCRIPTION_REQUIRED`, `ACCOUNT_LIMIT_EXCEEDED`) now map to CLI-friendly messages.

## [0.5.0] - 2026-04-20

### Added
Expand Down
16 changes: 16 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ src/
list.ts - List all categories
exchange-rate/
convert.ts - Currency conversion
ai/
usage.ts - AI usage and limits
parse-expenses.ts - Parse expense text
parse-expenses-image.ts - Parse up to 10 receipt images
insights.ts - Financial insight prompts
chat-message.ts - Lucas Chat agentic messages
```

## Tech Stack
Expand Down Expand Up @@ -170,6 +176,16 @@ Login flow writes human-readable output to stderr (not JSON) since it is interac
| GET | /api/stats/by-category | stats by-category |
| GET | /api/categories | categories list |
| GET | /api/exchange-rate/convert | exchange-rate convert |
| GET | /api/ai/usage | ai usage |
| POST | /api/ai/parse-expenses | ai parse-expenses |
| POST | /api/ai/parse-expenses-image | ai parse-expenses-image |
| POST | /api/ai/insights | ai insights |
| POST | /api/lucas-chat/message | ai chat-message |

## Public Plans

- `FREE`: 0 AI calls, max 3 active accounts, subscriptions blocked.
- `PREMIUM`: `$4/month`, unlimited accounts, unlimited subscriptions, AI limits of 50/day, 300/week, and 1000/month.

## Verification

Expand Down
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ lucas transactions list --from 2026-03-01
# Get a financial summary
lucas stats summary
lucas stats summary --year 2026 --month 3

# Check AI usage and parse expenses
lucas ai usage
lucas ai parse-expenses "lunch at Pardos S/ 35" --date 2026-05-08
```

## Commands
Expand All @@ -50,6 +54,7 @@ lucas stats summary --year 2026 --month 3
| `stats` | Financial statistics and reports |
| `categories` | View transaction categories |
| `exchange-rate` | Currency conversion |
| `ai` | AI usage, parsing, insights, chat |

### Authentication

Expand Down Expand Up @@ -220,6 +225,35 @@ lucas exchange-rate convert \
--amount 100 # Convert currencies
```

### AI

```bash
lucas ai usage # Show AI usage and limits
lucas ai usage --type chat # Filter usage by type when supported

lucas ai parse-expenses \
"lunch at Pardos S/ 35" \
--date 2026-05-08 # Parse expense text

lucas ai parse-expenses-image \
receipt-1.jpg receipt-2.png \
--date 2026-05-08 # Parse up to 10 receipt images

lucas ai insights \
"How am I doing this month?" \
--period month \
--currency PEN # Request financial insights

lucas ai chat-message \
"Show me this week's spending" \
--conversation-id <id> # Send a Lucas Chat message
```

Public plans are `FREE` and `PREMIUM`. `FREE` includes 0 AI calls, up to 3
active accounts, and no subscription access. `PREMIUM` costs `$4/month` and
includes unlimited accounts, unlimited subscriptions, and AI limits of
50/day, 300/week, and 1000/month.

### Unsetting Optional Fields

Use `--clear-<field>` to clear an optional field:
Expand Down
126 changes: 67 additions & 59 deletions bun.lock

Large diffs are not rendered by default.

18 changes: 12 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "lucasapp-cli",
"version": "0.5.0",
"version": "0.6.0",
"description": "LucasApp CLI - Financial data management for AI agents",
"author": "StevenACZ",
"license": "MIT",
Expand Down Expand Up @@ -37,13 +37,19 @@
"dependencies": {
"commander": "^14.0.3"
},
"overrides": {
"brace-expansion": "^5.0.6",
"picomatch": "^4.0.4",
"postcss": "^8.5.14",
"vite": "^8.0.11"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^25.5.0",
"eslint": "^10.1.0",
"prettier": "^3.8.1",
"@types/node": "^25.6.2",
"eslint": "^10.3.0",
"prettier": "^3.8.3",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.1",
"vitest": "^4.1.0"
"typescript-eslint": "^8.59.2",
"vitest": "^4.1.5"
}
}
31 changes: 31 additions & 0 deletions src/commands/ai/chat-message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Command } from "commander";
import { apiRequest } from "../../lib/api-client.js";
import { output } from "../../lib/output.js";

interface LucasChatMessageOptions {
conversationId?: string;
}

export async function runLucasChatMessage(
message: string,
opts: LucasChatMessageOptions,
) {
const body: Record<string, unknown> = { message };
if (opts.conversationId) body.conversationId = opts.conversationId;

const data = await apiRequest("POST", "/api/lucas-chat/message", body);
output.success(data);
}

export const lucasChatMessageCommand = new Command("chat-message")
.description("Send a message to Lucas Chat")
.argument("<message...>", "Message for Lucas Chat")
.option("--conversation-id <id>", "Existing conversation ID")
.action(async (messageParts: string[], opts: LucasChatMessageOptions) => {
const message = messageParts.join(" ").trim();
if (!message) {
output.error("Message is required");
}

await runLucasChatMessage(message, opts);
});
31 changes: 31 additions & 0 deletions src/commands/ai/insights.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Command } from "commander";
import { apiRequest } from "../../lib/api-client.js";
import { output } from "../../lib/output.js";

interface AIInsightsOptions {
period?: string;
currency?: string;
}

export async function runInsights(query: string, opts: AIInsightsOptions) {
const body: Record<string, unknown> = { query };
if (opts.period) body.period = opts.period;
if (opts.currency) body.currency = opts.currency;

const data = await apiRequest("POST", "/api/ai/insights", body);
output.success(data);
}

export const aiInsightsCommand = new Command("insights")
.description("Ask LucasApp AI for financial insights")
.argument("<query...>", "Insight prompt")
.option("--period <period>", "Analysis period, for example month")
.option("--currency <currency>", "Preferred currency")
.action(async (queryParts: string[], opts: AIInsightsOptions) => {
const query = queryParts.join(" ").trim();
if (!query) {
output.error("Query is required");
}

await runInsights(query, opts);
});
37 changes: 37 additions & 0 deletions src/commands/ai/parse-expenses-image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Command } from "commander";
import { apiRequest } from "../../lib/api-client.js";
import { readImagePayloads } from "../../lib/ai-contract.js";
import { output } from "../../lib/output.js";

interface ParseExpensesImageOptions {
date?: string;
}

export async function runParseExpensesImage(
paths: string[],
opts: ParseExpensesImageOptions,
) {
if (paths.length === 0) {
output.error("At least one image path is required");
}

const images = await readImagePayloads(paths).catch((error) => {
const message =
error instanceof Error ? error.message : "Invalid image input";
output.error(message, 400);
});

const body: Record<string, unknown> = { images };
if (opts.date) body.date = opts.date;

const data = await apiRequest("POST", "/api/ai/parse-expenses-image", body);
output.success(data);
}

export const parseExpensesImageCommand = new Command("parse-expenses-image")
.description("Parse up to 10 receipt images into expense transactions")
.argument("<paths...>", "Image file paths")
.option("--date <date>", "Context date (YYYY-MM-DD)")
.action(async (paths: string[], opts: ParseExpensesImageOptions) => {
await runParseExpensesImage(paths, opts);
});
31 changes: 31 additions & 0 deletions src/commands/ai/parse-expenses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Command } from "commander";
import { apiRequest } from "../../lib/api-client.js";
import { output } from "../../lib/output.js";

interface ParseExpensesOptions {
date?: string;
}

export async function runParseExpenses(
text: string,
opts: ParseExpensesOptions,
) {
const body: Record<string, unknown> = { text };
if (opts.date) body.date = opts.date;

const data = await apiRequest("POST", "/api/ai/parse-expenses", body);
output.success(data);
}

export const parseExpensesCommand = new Command("parse-expenses")
.description("Parse text into expense transactions")
.argument("<text...>", "Expense text to parse")
.option("--date <date>", "Context date (YYYY-MM-DD)")
.action(async (textParts: string[], opts: ParseExpensesOptions) => {
const text = textParts.join(" ").trim();
if (!text) {
output.error("Text is required");
}

await runParseExpenses(text, opts);
});
18 changes: 18 additions & 0 deletions src/commands/ai/usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Command } from "commander";
import { apiRequest } from "../../lib/api-client.js";
import { output } from "../../lib/output.js";

interface AIUsageOptions {
type?: string;
}

export async function runAIUsage(opts: AIUsageOptions) {
const query = opts.type ? { type: opts.type } : undefined;
const data = await apiRequest("GET", "/api/ai/usage", undefined, query);
output.success(data);
}

export const aiUsageCommand = new Command("usage")
.description("Show AI usage and limits")
.option("--type <type>", "Usage type filter, for example chat")
.action(runAIUsage);
15 changes: 15 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ import { listCategoriesCommand } from "./commands/categories/list.js";
// Exchange rate
import { convertCommand } from "./commands/exchange-rate/convert.js";

// AI
import { aiUsageCommand } from "./commands/ai/usage.js";
import { parseExpensesCommand } from "./commands/ai/parse-expenses.js";
import { parseExpensesImageCommand } from "./commands/ai/parse-expenses-image.js";
import { aiInsightsCommand } from "./commands/ai/insights.js";
import { lucasChatMessageCommand } from "./commands/ai/chat-message.js";

const program = new Command();

program
Expand Down Expand Up @@ -129,5 +136,13 @@ const exchangeRate = program
.description("Currency exchange");
exchangeRate.addCommand(convertCommand);

// Grupo: ai
const ai = program.command("ai").description("LucasApp AI tools");
ai.addCommand(aiUsageCommand);
ai.addCommand(parseExpensesCommand);
ai.addCommand(parseExpensesImageCommand);
ai.addCommand(aiInsightsCommand);
ai.addCommand(lucasChatMessageCommand);

await maybeNotifyForUpdate(CLI_VERSION);
await program.parseAsync(process.argv);
58 changes: 58 additions & 0 deletions src/lib/ai-contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { readFile } from "fs/promises";
import { extname } from "path";

export const USER_PLANS = ["FREE", "PREMIUM"] as const;
export type UserPlan = (typeof USER_PLANS)[number];

export const AI_IMAGE_LIMIT = 10;

export const PLAN_FEATURES: Record<UserPlan, string[]> = {
FREE: ["0 AI calls", "Max 3 active accounts", "Subscriptions blocked"],
PREMIUM: [
"Unlimited accounts",
"Unlimited subscriptions",
"AI limits: 50/day, 300/week, 1000/month",
],
};

export interface AIImagePayload {
base64: string;
mimeType: string;
}

export function assertImageLimit(images: ArrayLike<unknown>): void {
if (images.length > AI_IMAGE_LIMIT) {
throw new Error(`Maximum ${AI_IMAGE_LIMIT} images per request`);
}
}

export function mimeTypeForPath(filePath: string): string {
switch (extname(filePath).toLowerCase()) {
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".png":
return "image/png";
case ".webp":
return "image/webp";
case ".heic":
return "image/heic";
default:
return "application/octet-stream";
}
}

export async function readImagePayloads(
paths: string[],
): Promise<AIImagePayload[]> {
assertImageLimit(paths);

const images = await Promise.all(
paths.map(async (path) => ({
base64: (await readFile(path)).toString("base64"),
mimeType: mimeTypeForPath(path),
})),
);

return images;
}
Loading