@usemeno/meno-cli 0.1.0

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.
@@ -0,0 +1,112 @@
1
+ import enquirer from "enquirer";
2
+ import chalk from "chalk";
3
+ import ora from "ora";
4
+ import { getSelectedProject } from "../config.js";
5
+ import { apiRequest, ApiError, Project } from "../utils/api.js";
6
+ import { parseDuration } from "../utils/timer.js";
7
+
8
+ interface LogOptions {
9
+ duration?: string;
10
+ project?: string;
11
+ }
12
+
13
+ export async function logTime(description: string, options: LogOptions) {
14
+ console.log(chalk.bold("\nšŸ“ Log Time Entry\n"));
15
+
16
+ let projectId = options.project;
17
+ let duration = options.duration ? parseDuration(options.duration) : null;
18
+ let projectName = "";
19
+ let hourlyRate = 0;
20
+
21
+ // Determine project
22
+ if (!projectId) {
23
+ const selected = getSelectedProject();
24
+ if (selected) {
25
+ projectId = selected.id;
26
+ projectName = selected.name;
27
+ hourlyRate = selected.hourlyRate;
28
+ } else {
29
+ // Need to prompt for project
30
+ const spinner = ora("Loading projects...").start();
31
+ try {
32
+ const { projects } = await apiRequest<{ projects: Project[] }>("/api/external/projects");
33
+ spinner.stop();
34
+
35
+ if (projects.length === 0) {
36
+ console.log(chalk.yellow("No active projects found.\n"));
37
+ return;
38
+ }
39
+
40
+ const result = await enquirer.prompt({
41
+ type: "select",
42
+ name: "project",
43
+ message: "Select a project:",
44
+ choices: projects.map((p) => ({
45
+ name: p.id,
46
+ message: `${p.name} (${p.clientName}) • $${p.hourlyRate}/hr`,
47
+ value: p,
48
+ })),
49
+ });
50
+
51
+ const answer = (result as any).project;
52
+
53
+ // enquirer returns the choice "name" (ID) not "value" (object)
54
+ const project = projects.find((p) => p.id === answer);
55
+ if (!project) {
56
+ console.log(chalk.red("Error: Selected project not found\n"));
57
+ return;
58
+ }
59
+
60
+ projectId = project.id;
61
+ projectName = project.name;
62
+ hourlyRate = project.hourlyRate;
63
+ } catch (error) {
64
+ spinner.fail(chalk.red("Failed to load projects"));
65
+ if (error instanceof ApiError) {
66
+ console.log(chalk.red(`\nError: ${error.message}\n`));
67
+ }
68
+ return;
69
+ }
70
+ }
71
+ }
72
+
73
+ // Parse duration if not provided
74
+ if (!duration) {
75
+ console.log(chalk.yellow("\n⚠ Duration is required. Use --duration flag (e.g., 45m, 1.5h)\n"));
76
+ return;
77
+ }
78
+
79
+ if (duration <= 0) {
80
+ console.log(chalk.red("\nāœ— Invalid duration\n"));
81
+ return;
82
+ }
83
+
84
+ // Log the entry
85
+ const spinner = ora("Logging entry...").start();
86
+
87
+ try {
88
+ const response = await apiRequest("/api/external/time-entries", {
89
+ method: "POST",
90
+ body: JSON.stringify({
91
+ projectId,
92
+ description,
93
+ duration,
94
+ billable: true,
95
+ }),
96
+ });
97
+
98
+ const amount = duration * (hourlyRate || response.entry.amount / duration);
99
+ spinner.succeed(
100
+ chalk.green(`āœ“ Logged ${duration.toFixed(2)} hours to ${projectName || response.entry.projectName} ($${amount.toFixed(2)})`)
101
+ );
102
+ console.log();
103
+ } catch (error) {
104
+ spinner.fail(chalk.red("Failed to log entry"));
105
+
106
+ if (error instanceof ApiError) {
107
+ console.log(chalk.red(`\nError: ${error.message}\n`));
108
+ } else {
109
+ console.log(chalk.red(`\nUnexpected error: ${(error as Error).message}\n`));
110
+ }
111
+ }
112
+ }
@@ -0,0 +1,68 @@
1
+ import enquirer from "enquirer";
2
+ import ora from "ora";
3
+ import chalk from "chalk";
4
+ import { setApiKey, setBaseUrl } from "../config.js";
5
+ import { apiRequest, ApiError} from "../utils/api.js";
6
+
7
+ export async function login() {
8
+ console.log(chalk.bold("\nšŸ”‘ Meno CLI Login\n"));
9
+
10
+ try {
11
+ // Prompt for API key
12
+ const apiKeyPrompt = await enquirer.prompt({
13
+ type: "password",
14
+ name: "apiKey",
15
+ message: "Enter your API key:",
16
+ validate: (value: string) => {
17
+ if (!value || !value.startsWith("meno_sk_")) {
18
+ return "Invalid API key format. Expected: meno_sk_...";
19
+ }
20
+ return true;
21
+ },
22
+ });
23
+
24
+ const apiKey = (apiKeyPrompt as any).apiKey;
25
+
26
+ // Optional: Custom base URL
27
+ const baseUrlPrompt = await enquirer.prompt({
28
+ type: "input",
29
+ name: "baseUrl",
30
+ message: "Base URL (press Enter for default):",
31
+ initial: "http://localhost:3000",
32
+ });
33
+
34
+ const baseUrl = (baseUrlPrompt as any).baseUrl;
35
+
36
+ // Validate API key by making a test request
37
+ const spinner = ora("Validating API key...").start();
38
+
39
+ try {
40
+ // Temporarily set the key to test it
41
+ setApiKey(apiKey as string);
42
+ if (baseUrl) {
43
+ setBaseUrl(baseUrl as string);
44
+ }
45
+
46
+ // Test the key
47
+ await apiRequest("/api/external/projects");
48
+
49
+ spinner.succeed(chalk.green("āœ“ Logged in successfully!"));
50
+ console.log(chalk.dim(`\nAPI key stored securely.`));
51
+ console.log(chalk.dim(`Run ${chalk.bold("meno select")} to choose a project.\n`));
52
+ } catch (error) {
53
+ spinner.fail(chalk.red("āœ— Authentication failed"));
54
+
55
+ if (error instanceof ApiError) {
56
+ console.log(chalk.red(`\nError: ${error.message}\n`));
57
+ } else {
58
+ console.log(chalk.red(`\nUnexpected error: ${(error as Error).message}\n`));
59
+ }
60
+
61
+ process.exit(1);
62
+ }
63
+ } catch (error) {
64
+ // User cancelled
65
+ console.log(chalk.yellow("\nāœ— Login cancelled\n"));
66
+ process.exit(0);
67
+ }
68
+ }
@@ -0,0 +1,86 @@
1
+ import enquirer from "enquirer";
2
+ import ora from "ora";
3
+ import chalk from "chalk";
4
+ import { setSelectedProject, clearSelectedProject, getSelectedProject } from "../config.js";
5
+ import { apiRequest, ApiError, Project as ApiProject } from "../utils/api.js";
6
+
7
+ export async function selectProject() {
8
+ console.log(chalk.bold("\nšŸ“‚ Select Project\n"));
9
+
10
+ const spinner = ora("Loading projects...").start();
11
+
12
+ try {
13
+ const { projects } = await apiRequest<{ projects: ApiProject[] }>("/api/external/projects");
14
+
15
+ spinner.stop();
16
+
17
+ if (projects.length === 0) {
18
+ console.log(chalk.yellow("No active projects found."));
19
+ console.log(chalk.dim("Create a project in the web app first.\n"));
20
+ return;
21
+ }
22
+
23
+ const currentProject = getSelectedProject();
24
+
25
+ // Build choices
26
+ const choices = [
27
+ {
28
+ name: "clear",
29
+ message: chalk.dim("[Clear Selection]"),
30
+ },
31
+ ...projects.map((project) => {
32
+ const isSelected = currentProject?.id === project.id;
33
+ const prefix = isSelected ? "ā— " : " ";
34
+ const displayName = `${project.name} (${project.clientName}) • $${project.hourlyRate}/hr`;
35
+
36
+ return {
37
+ name: project.id,
38
+ message: prefix + displayName,
39
+ value: project,
40
+ };
41
+ }),
42
+ ];
43
+
44
+ const result = await enquirer.prompt({
45
+ type: "select",
46
+ name: "project",
47
+ message: "Select a project:",
48
+ choices,
49
+ });
50
+
51
+ const answer = (result as any).project;
52
+
53
+ if (answer === "clear") {
54
+ clearSelectedProject();
55
+ console.log(chalk.green("\nāœ“ Selection cleared\n"));
56
+ } else {
57
+ // Find the full project object by ID
58
+ const project = projects.find((p) => p.id === answer);
59
+
60
+ if (!project) {
61
+ console.log(chalk.red("\nāœ— Project not found\n"));
62
+ return;
63
+ }
64
+
65
+ setSelectedProject({
66
+ id: project.id,
67
+ name: project.name,
68
+ clientName: project.clientName,
69
+ hourlyRate: project.hourlyRate,
70
+ });
71
+ console.log(
72
+ chalk.green(`\nāœ“ Selected: ${chalk.bold(project.name)} (${project.clientName}) • $${project.hourlyRate}/hr\n`)
73
+ );
74
+ }
75
+ } catch (error) {
76
+ spinner.fail(chalk.red("Failed to load projects"));
77
+
78
+ if (error instanceof ApiError) {
79
+ console.log(chalk.red(`\nError: ${error.message}\n`));
80
+ } else {
81
+ console.log(chalk.red(`\nUnexpected error: ${(error as Error).message}\n`));
82
+ }
83
+
84
+ process.exit(1);
85
+ }
86
+ }
@@ -0,0 +1,77 @@
1
+ import chalk from "chalk";
2
+ import ora from "ora";
3
+ import { getSelectedProject, getActiveTimer, setActiveTimer } from "../config.js";
4
+ import { apiRequest, ApiError, Project } from "../utils/api.js";
5
+ import { formatDuration } from "../utils/timer.js";
6
+
7
+ export async function startTimer(projectId?: string) {
8
+ // Check if timer is already running
9
+ const existingTimer = getActiveTimer();
10
+ if (existingTimer) {
11
+ const elapsed = formatDuration(existingTimer.startTime);
12
+ console.log(
13
+ chalk.yellow(
14
+ `\n⚠ Timer already running for ${chalk.bold(existingTimer.projectName)} (${elapsed})`
15
+ )
16
+ );
17
+ console.log(chalk.dim(`Stop it first with ${chalk.bold("meno stop")} or discard with ${chalk.bold("meno stop --discard")}\n`));
18
+ return;
19
+ }
20
+
21
+ let project: Project | undefined;
22
+
23
+ if (projectId) {
24
+ // Start with explicit project ID
25
+ const spinner = ora("Loading project...").start();
26
+ try {
27
+ const { projects } = await apiRequest<{ projects: Project[] }>("/api/external/projects");
28
+ project = projects.find((p) => p.id === projectId);
29
+ spinner.stop();
30
+
31
+ if (!project) {
32
+ console.log(chalk.red(`\nāœ— Project not found: ${projectId}\n`));
33
+ return;
34
+ }
35
+ } catch (error) {
36
+ spinner.fail(chalk.red("Failed to load project"));
37
+ if (error instanceof ApiError) {
38
+ console.log(chalk.red(`\nError: ${error.message}\n`));
39
+ }
40
+ return;
41
+ }
42
+ } else {
43
+ // Use selected project
44
+ const selected = getSelectedProject();
45
+ if (!selected) {
46
+ console.log(chalk.yellow(`\n⚠ No project selected.`));
47
+ console.log(chalk.dim(`Run ${chalk.bold("meno select")} first or provide a project ID: ${chalk.bold("meno start <project-id>")}\n`));
48
+ return;
49
+ }
50
+
51
+ project = {
52
+ id: selected.id,
53
+ name: selected.name,
54
+ clientName: selected.clientName,
55
+ clientCompany: "",
56
+ hourlyRate: selected.hourlyRate,
57
+ taxRate: 0,
58
+ weeklyHourLimit: 0,
59
+ hoursUsed: 0,
60
+ };
61
+ }
62
+
63
+ // Start the timer
64
+ const startTime = new Date().toISOString();
65
+ setActiveTimer({
66
+ projectId: project.id,
67
+ projectName: project.name,
68
+ startTime,
69
+ });
70
+
71
+ const timestamp = new Date().toLocaleTimeString();
72
+ console.log(
73
+ chalk.green(
74
+ `\nā±ļø Started: ${chalk.bold(project.name)} • $${project.hourlyRate}/hr • [${timestamp}]\n`
75
+ )
76
+ );
77
+ }
@@ -0,0 +1,62 @@
1
+ import chalk from "chalk";
2
+ import ora from "ora";
3
+ import { getSelectedProject, getActiveTimer } from "../config.js";
4
+ import { apiRequest, ApiError, Stats } from "../utils/api.js";
5
+ import { formatDuration, calculateTimerValue } from "../utils/timer.js";
6
+
7
+ export async function showStatus() {
8
+ console.log(chalk.bold("\nšŸ“Š Meno Status\n"));
9
+
10
+ const selected = getSelectedProject();
11
+ const timer = getActiveTimer();
12
+
13
+ // Show selected project
14
+ if (selected) {
15
+ console.log(
16
+ chalk.cyan(
17
+ `šŸ“Œ Selected: ${chalk.bold(selected.name)} (${selected.clientName}) • $${selected.hourlyRate}/hr`
18
+ )
19
+ );
20
+ }
21
+
22
+ // Show active timer
23
+ if (timer) {
24
+ const elapsed = formatDuration(timer.startTime);
25
+ const value = calculateTimerValue(timer.startTime, selected?.hourlyRate || 0);
26
+ console.log(
27
+ chalk.green(
28
+ `ā±ļø Running: ${chalk.bold(timer.projectName)} • ${elapsed} • $${value.toFixed(2)}`
29
+ )
30
+ );
31
+ }
32
+
33
+ if (selected || timer) {
34
+ console.log(); // Add spacing
35
+ }
36
+
37
+ // Fetch unbilled stats
38
+ const spinner = ora("Loading stats...").start();
39
+
40
+ try {
41
+ const stats = await apiRequest<Stats>("/api/external/stats");
42
+ spinner.stop();
43
+
44
+ console.log(
45
+ chalk.green.bold(`šŸ’° Unbilled Revenue: $${stats.totalRevenue.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`)
46
+ );
47
+ console.log(
48
+ chalk.dim(
49
+ `ā±ļø Total Hours: ${stats.totalHours.toFixed(1)} hours across ${stats.entriesCount} ${stats.entriesCount === 1 ? "entry" : "entries"}`
50
+ )
51
+ );
52
+ console.log();
53
+ } catch (error) {
54
+ spinner.fail(chalk.red("Failed to load stats"));
55
+
56
+ if (error instanceof ApiError) {
57
+ console.log(chalk.red(`\nError: ${error.message}\n`));
58
+ } else {
59
+ console.log(chalk.red(`\nUnexpected error: ${(error as Error).message}\n`));
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,132 @@
1
+ import enquirer from "enquirer";
2
+ import chalk from "chalk";
3
+ import ora from "ora";
4
+ import { getActiveTimer, clearActiveTimer, getSelectedProject } from "../config.js";
5
+ import { apiRequest, ApiError } from "../utils/api.js";
6
+ import { formatDuration, calculateElapsedHours, parseDuration } from "../utils/timer.js";
7
+
8
+ interface StopOptions {
9
+ discard?: boolean;
10
+ description?: string;
11
+ noConfirm?: boolean;
12
+ yes?: boolean;
13
+ adjust?: string;
14
+ }
15
+
16
+ export async function stopTimer(options: StopOptions) {
17
+ const timer = getActiveTimer();
18
+
19
+ if (!timer) {
20
+ console.log(chalk.yellow(`\n⚠ No active timer.`));
21
+ console.log(chalk.dim(`Start one with ${chalk.bold("meno start")}\n`));
22
+ return;
23
+ }
24
+
25
+ // If discard flag, just clear and exit
26
+ if (options.discard) {
27
+ clearActiveTimer();
28
+ console.log(chalk.yellow(`\nāœ— Timer discarded\n`));
29
+ return;
30
+ }
31
+
32
+ // Calculate duration (or use adjusted duration)
33
+ let duration: number;
34
+ let formatted: string;
35
+
36
+ if (options.adjust) {
37
+ // Parse adjusted duration
38
+ duration = parseDuration(options.adjust);
39
+ if (duration === 0) {
40
+ console.log(chalk.red(`\nāœ— Invalid duration format: ${options.adjust}\n`));
41
+ console.log(chalk.dim(`Use format like: 1.5h, 90m, 45\n`));
42
+ return;
43
+ }
44
+ const hours = Math.floor(duration);
45
+ const minutes = Math.round((duration - hours) * 60);
46
+ formatted = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
47
+ console.log(chalk.yellow(`\n⚠ Adjusted duration from timer\n`));
48
+ } else {
49
+ duration = calculateElapsedHours(timer.startTime);
50
+ formatted = formatDuration(timer.startTime);
51
+ }
52
+
53
+ // Get project details for rate calculation
54
+ const project = getSelectedProject();
55
+ const hourlyRate = project?.hourlyRate || 0;
56
+ const amount = duration * hourlyRate;
57
+
58
+ console.log(chalk.bold(`\nā±ļø Duration: ${formatted} • $${amount.toFixed(2)}\n`));
59
+
60
+ let description: string;
61
+ let shouldLog = true;
62
+
63
+ try {
64
+ // Get description (from flag or prompt)
65
+ if (options.description) {
66
+ description = options.description;
67
+ console.log(chalk.dim(`Description: ${description}\n`));
68
+ } else {
69
+ const descResult = await enquirer.prompt({
70
+ type: "input",
71
+ name: "description",
72
+ message: "What were you working on?",
73
+ initial: timer.description || "",
74
+ validate: (value: string) => {
75
+ if (!value || value.trim().length === 0) {
76
+ return "Description is required";
77
+ }
78
+ return true;
79
+ },
80
+ });
81
+ description = (descResult as any).description;
82
+ }
83
+
84
+ // Confirm logging (unless --no-confirm or -y flag)
85
+ const skipConfirm = options.noConfirm || options.yes;
86
+ if (!skipConfirm) {
87
+ const confirmResult = await enquirer.prompt({
88
+ type: "confirm",
89
+ name: "shouldLog",
90
+ message: "Log this entry?",
91
+ initial: true,
92
+ });
93
+ shouldLog = (confirmResult as any).shouldLog;
94
+
95
+ if (!shouldLog) {
96
+ console.log(chalk.yellow("\nāœ— Entry not logged\n"));
97
+ return;
98
+ }
99
+ }
100
+
101
+ // Log the entry
102
+ const spinner = ora("Logging entry...").start();
103
+
104
+ try {
105
+ const response = await apiRequest("/api/external/time-entries", {
106
+ method: "POST",
107
+ body: JSON.stringify({
108
+ projectId: timer.projectId,
109
+ description: description,
110
+ duration: Math.round(duration * 100) / 100, // Round to 2 decimals
111
+ date: new Date().toISOString().split("T")[0], // Today
112
+ billable: true,
113
+ }),
114
+ });
115
+
116
+ clearActiveTimer();
117
+ spinner.succeed(chalk.green(`āœ“ Logged ${duration.toFixed(2)} hours to ${timer.projectName}`));
118
+ console.log(chalk.dim(`\nEntry created successfully\n`));
119
+ } catch (error) {
120
+ spinner.fail(chalk.red("Failed to log entry"));
121
+
122
+ if (error instanceof ApiError) {
123
+ console.log(chalk.red(`\nError: ${error.message}\n`));
124
+ } else {
125
+ console.log(chalk.red(`\nUnexpected error: ${(error as Error).message}\n`));
126
+ }
127
+ }
128
+ } catch (error) {
129
+ // User cancelled
130
+ console.log(chalk.yellow("\nāœ— Cancelled\n"));
131
+ }
132
+ }
package/src/config.ts ADDED
@@ -0,0 +1,76 @@
1
+ import Conf from "conf";
2
+
3
+ interface Config {
4
+ apiKey?: string;
5
+ baseUrl?: string;
6
+ selectedProject?: {
7
+ id: string;
8
+ name: string;
9
+ clientName: string;
10
+ hourlyRate: number;
11
+ };
12
+ activeTimer?: {
13
+ projectId: string;
14
+ projectName: string;
15
+ startTime: string;
16
+ description?: string;
17
+ };
18
+ }
19
+
20
+ const config = new Conf<Config>({
21
+ projectName: "meno-cli",
22
+ });
23
+
24
+ export function getApiKey(): string | undefined {
25
+ return config.get("apiKey");
26
+ }
27
+
28
+ export function setApiKey(key: string): void {
29
+ config.set("apiKey", key);
30
+ }
31
+
32
+ export function getBaseUrl(): string {
33
+ return config.get("baseUrl") || process.env.MENO_BASE_URL || "http://localhost:3000";
34
+ }
35
+
36
+ export function setBaseUrl(url: string): void {
37
+ config.set("baseUrl", url);
38
+ }
39
+
40
+ export function getSelectedProject() {
41
+ return config.get("selectedProject");
42
+ }
43
+
44
+ export function setSelectedProject(project: {
45
+ id: string;
46
+ name: string;
47
+ clientName: string;
48
+ hourlyRate: number;
49
+ }): void {
50
+ config.set("selectedProject", project);
51
+ }
52
+
53
+ export function clearSelectedProject(): void {
54
+ config.delete("selectedProject");
55
+ }
56
+
57
+ export function getActiveTimer() {
58
+ return config.get("activeTimer");
59
+ }
60
+
61
+ export function setActiveTimer(timer: {
62
+ projectId: string;
63
+ projectName: string;
64
+ startTime: string;
65
+ description?: string;
66
+ }): void {
67
+ config.set("activeTimer", timer);
68
+ }
69
+
70
+ export function clearActiveTimer(): void {
71
+ config.delete("activeTimer");
72
+ }
73
+
74
+ export function clearConfig(): void {
75
+ config.clear();
76
+ }
package/src/index.ts ADDED
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from "commander";
4
+ import { login } from "./commands/login.js";
5
+ import { selectProject } from "./commands/select.js";
6
+ import { startTimer } from "./commands/start.js";
7
+ import { stopTimer } from "./commands/stop.js";
8
+ import { logTime } from "./commands/log.js";
9
+ import { showStatus } from "./commands/status.js";
10
+
11
+ const program = new Command();
12
+
13
+ program
14
+ .name("meno")
15
+ .description("CLI for Meno time tracking")
16
+ .version("0.1.0");
17
+
18
+ program
19
+ .command("login")
20
+ .description("Authenticate with your Meno API key")
21
+ .action(login);
22
+
23
+ program
24
+ .command("select")
25
+ .description("Select a project to work on")
26
+ .action(selectProject);
27
+
28
+ program
29
+ .command("start [project-id]")
30
+ .description("Start a timer on selected project or specific project ID")
31
+ .action(startTimer);
32
+
33
+ program
34
+ .command("stop")
35
+ .description("Stop the running timer and log the entry")
36
+ .option("--discard", "Discard the timer without logging")
37
+ .option("-d, --description <text>", "Entry description (skip prompt)")
38
+ .option("-y, --yes", "Auto-confirm (skip confirmation prompt)")
39
+ .option("--no-confirm", "Skip confirmation prompt")
40
+ .option("--adjust <duration>", "Override calculated duration (e.g., 1.5h, 45m)")
41
+ .action(stopTimer);
42
+
43
+ program
44
+ .command("log <description>")
45
+ .description("Log time manually")
46
+ .option("-d, --duration <duration>", "Duration (e.g., 45m, 1.5h, 90)")
47
+ .option("-p, --project <id>", "Project ID")
48
+ .action(logTime);
49
+
50
+ program
51
+ .command("status")
52
+ .description("Show selected project, timer status, and unbilled stats")
53
+ .action(showStatus);
54
+
55
+ program.parse();