@towles/tool 0.0.11 → 0.0.12

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.
Files changed (3) hide show
  1. package/README.md +9 -9
  2. package/dist/index.mjs +510 -210
  3. package/package.json +13 -4
package/README.md CHANGED
@@ -74,7 +74,7 @@ if that works, then you need to add the pnpm global bin directory to your PATH.
74
74
  - [rolldown-vite](https://voidzero.dev/posts/announcing-rolldown-vite) - A Vite plugin for rolling down your code
75
75
  - ~~[zx](https://github.com/google/zx) google created library to write shell scripts in a more powerful and expressive way via the Anthropic API.~~
76
76
  - [prompts](https://github.com/terkelg/prompts) - A library for creating beautiful command-line prompts, with fuzzy search and other features.
77
- - [commander](https://github.com/tj/commander.js) - A library for building command-line interfaces, with support for subcommands, argument parsing, and more.
77
+ - [yargs](https://github.com/yargs/yargs) - A modern, feature-rich command-line argument parser with enhanced error handling, TypeScript support, and flexible command configuration.
78
78
 
79
79
  ## Document verbose and debug options
80
80
 
@@ -98,13 +98,13 @@ I'm using a lot of inspiration from [Anthony Fu](https://github.com/antfu) for t
98
98
 
99
99
  <!-- Badges -->
100
100
 
101
- [npm-version-src]: https://img.shields.io/npm/v/pkg-placeholder?style=flat&colorA=080f12&colorB=1fa669
102
- [npm-version-href]: https://npmjs.com/package/pkg-placeholder
103
- [npm-downloads-src]: https://img.shields.io/npm/dm/pkg-placeholder?style=flat&colorA=080f12&colorB=1fa669
104
- [npm-downloads-href]: https://npmjs.com/package/pkg-placeholder
105
- [bundle-src]: https://img.shields.io/bundlephobia/minzip/pkg-placeholder?style=flat&colorA=080f12&colorB=1fa669&label=minzip
106
- [bundle-href]: https://bundlephobia.com/result?p=pkg-placeholder
107
- [license-src]: https://img.shields.io/github/license/antfu/pkg-placeholder.svg?style=flat&colorA=080f12&colorB=1fa669
101
+ [npm-version-src]: https://img.shields.io/npm/v/@towles/tool?style=flat&colorA=080f12&colorB=1fa669
102
+ [npm-version-href]: https://npmjs.com/package/@towles/tool
103
+ [npm-downloads-src]: https://img.shields.io/npm/dm/@towles/tool?style=flat&colorA=080f12&colorB=1fa669
104
+ [npm-downloads-href]: https://npmjs.com/package/@towles/tool
105
+ [bundle-src]: https://img.shields.io/bundlephobia/minzip/@towles/tool?style=flat&colorA=080f12&colorB=1fa669&label=minzip
106
+ [bundle-href]: https://bundlephobia.com/result?p=@towles/tool
107
+ [license-src]: https://img.shields.io/github/license/ChrisTowles/towles-tool.svg?style=flat&colorA=080f12&colorB=1fa669
108
108
  [license-href]: https://github.com/ChrisTowles/towles-tool/blob/main/LICENSE.md
109
109
  [jsdocs-src]: https://img.shields.io/badge/jsdocs-reference-080f12?style=flat&colorA=080f12&colorB=1fa669
110
- [jsdocs-href]: https://www.jsdocs.io/package/pkg-placeholder
110
+ [jsdocs-href]: https://www.jsdocs.io/package/@towles/tool
package/dist/index.mjs CHANGED
@@ -1,115 +1,313 @@
1
1
  #!/usr/bin/env node
2
- import process from 'node:process';
3
- import { Command } from 'commander';
4
- import consola from 'consola';
5
- import { colors } from 'consola/utils';
6
- import prompts from 'prompts';
2
+ import process$1 from 'node:process';
3
+ import yargs from 'yargs';
4
+ import { hideBin } from 'yargs/helpers';
5
+ import { useState, createContext, Component, useEffect } from 'react';
6
+ import { Box, Text, useInput, render } from 'ink';
7
7
  import { execSync, exec } from 'node:child_process';
8
+ import * as fs from 'node:fs';
8
9
  import { existsSync, writeFileSync, mkdirSync } from 'node:fs';
9
10
  import * as path from 'node:path';
10
11
  import path__default from 'node:path';
11
- import util, { promisify } from 'node:util';
12
+ import { promisify } from 'node:util';
13
+ import consola from 'consola';
14
+ import { colors } from 'consola/utils';
15
+ import { DateTime } from 'luxon';
16
+ import { z } from 'zod/v4';
12
17
  import { homedir } from 'node:os';
13
- import { setupDotenv, loadConfig } from 'c12';
14
- import { updateConfig } from 'c12/update';
18
+ import stripJsonComments from 'strip-json-comments';
15
19
 
16
- const version = "0.0.11";
20
+ const version = "0.0.12";
17
21
 
18
- function execCommand(cmd, cwd) {
19
- return execSync(cmd, { encoding: "utf8", cwd }).trim();
22
+ const AppContext = createContext(null);
23
+ function AppProvider({ children, initialCommandContext }) {
24
+ const [appState, setAppState] = useState({
25
+ isLoading: false,
26
+ error: null,
27
+ currentCommand: null
28
+ });
29
+ const [commandContext, setCommandContext] = useState({
30
+ mode: "interactive",
31
+ exitCode: 0,
32
+ onExit: (code = 0) => process.exit(code),
33
+ ...initialCommandContext
34
+ });
35
+ const updateAppState = (updates) => {
36
+ setAppState((prev) => ({ ...prev, ...updates }));
37
+ };
38
+ const updateCommandContext = (updates) => {
39
+ setCommandContext((prev) => ({ ...prev, ...updates }));
40
+ };
41
+ return /* @__PURE__ */ React.createElement(AppContext.Provider, { value: {
42
+ appState,
43
+ updateAppState,
44
+ commandContext,
45
+ updateCommandContext
46
+ } }, children);
20
47
  }
21
48
 
22
- async function gitCommitCommand(config, messageArgs) {
23
- let statusOutput;
24
- try {
25
- statusOutput = execCommand("git status --porcelain", config.cwd);
26
- } catch (error) {
27
- consola.error("Failed to get git status");
28
- process.exit(1);
29
- }
30
- const lines = statusOutput.trim().split("\n").filter((line) => line.length > 0);
31
- const stagedFiles = lines.filter((line) => line[0] !== " " && line[0] !== "?");
32
- const unstagedFiles = lines.filter((line) => line[1] !== " " && line[1] !== "?");
33
- const untrackedFiles = lines.filter((line) => line.startsWith("??"));
34
- if (lines.length === 0) {
35
- consola.info("Working tree clean - nothing to commit");
36
- return;
49
+ const ConfigContext = createContext(null);
50
+ function ConfigProvider({ children, context }) {
51
+ return /* @__PURE__ */ React.createElement(ConfigContext.Provider, { value: { context } }, children);
52
+ }
53
+
54
+ const AppInfo = {
55
+ toolName: "towles-tool"
56
+ };
57
+ const DEFAULT_THEME = {
58
+ primary: "cyan",
59
+ warning: "yellow",
60
+ error: "red",
61
+ dim: "dim"
62
+ };
63
+
64
+ class ErrorBoundary extends Component {
65
+ constructor(props) {
66
+ super(props);
67
+ this.state = { hasError: false };
37
68
  }
38
- consola.info("Git status:");
39
- if (stagedFiles.length > 0) {
40
- consola.info(colors.green("Staged files:"));
41
- stagedFiles.forEach((file) => consola.info(` ${colors.green(file)}`));
69
+ static getDerivedStateFromError(error) {
70
+ return { hasError: true, error };
42
71
  }
43
- if (unstagedFiles.length > 0) {
44
- consola.info(colors.yellow("Modified files (not staged):"));
45
- unstagedFiles.forEach((file) => consola.info(` ${colors.yellow(file)}`));
72
+ componentDidCatch(error, errorInfo) {
46
73
  }
47
- if (untrackedFiles.length > 0) {
48
- consola.info(colors.red("Untracked files:"));
49
- untrackedFiles.forEach((file) => consola.info(` ${colors.red(file)}`));
74
+ render() {
75
+ if (this.state.hasError) {
76
+ if (this.props.fallback) {
77
+ return this.props.fallback;
78
+ }
79
+ return /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", padding: 1 }, /* @__PURE__ */ React.createElement(Text, { bold: true, color: DEFAULT_THEME.error }, "Application Error"), /* @__PURE__ */ React.createElement(Text, { color: DEFAULT_THEME.error }, this.state.error?.message || "An unexpected error occurred"), /* @__PURE__ */ React.createElement(Text, { color: DEFAULT_THEME.dim }, "Press ESC to exit"));
80
+ }
81
+ return this.props.children;
50
82
  }
51
- if (stagedFiles.length === 0) {
52
- if (unstagedFiles.length > 0 || untrackedFiles.length > 0) {
53
- const { shouldStage } = await prompts({
54
- type: "confirm",
55
- name: "shouldStage",
56
- message: "No files are staged. Would you like to add files first?",
57
- initial: true
58
- });
59
- if (shouldStage) {
60
- const { addAll } = await prompts({
61
- type: "confirm",
62
- name: "addAll",
63
- message: "Add all modified and untracked files?",
64
- initial: true
65
- });
66
- if (addAll) {
67
- try {
68
- execCommand("git add .", config.cwd);
69
- consola.success("All files staged successfully");
70
- } catch (error) {
71
- consola.error("Failed to stage files");
72
- process.exit(1);
73
- }
83
+ }
84
+
85
+ function execCommand(cmd, cwd) {
86
+ return execSync(cmd, { encoding: "utf8", cwd }).trim();
87
+ }
88
+
89
+ function useGitOperations(cwd) {
90
+ const [loading, setLoading] = useState(false);
91
+ const [error, setError] = useState(null);
92
+ const getGitStatus = async () => {
93
+ try {
94
+ setLoading(true);
95
+ setError(null);
96
+ const statusOutput = execCommand("git status --porcelain", cwd);
97
+ const lines = statusOutput.trim().split("\n").filter((line) => line.length > 0);
98
+ const staged = lines.filter((line) => line[0] !== " " && line[0] !== "?").map((line) => line.slice(3));
99
+ const unstaged = lines.filter((line) => line[1] !== " " && line[1] !== "?").map((line) => line.slice(3));
100
+ const untracked = lines.filter((line) => line.startsWith("??")).map((line) => line.slice(3));
101
+ return { staged, unstaged, untracked };
102
+ } catch (err) {
103
+ setError("Failed to get git status");
104
+ return null;
105
+ } finally {
106
+ setLoading(false);
107
+ }
108
+ };
109
+ const stageFiles = async (files) => {
110
+ try {
111
+ setLoading(true);
112
+ setError(null);
113
+ if (files.length === 0) return false;
114
+ const command = files.includes(".") ? "git add ." : `git add ${files.map((f) => `"${f}"`).join(" ")}`;
115
+ execCommand(command, cwd);
116
+ return true;
117
+ } catch (err) {
118
+ setError("Failed to stage files");
119
+ return false;
120
+ } finally {
121
+ setLoading(false);
122
+ }
123
+ };
124
+ const commit = async (message) => {
125
+ try {
126
+ setLoading(true);
127
+ setError(null);
128
+ const escapedMessage = message.replace(/"/g, '\\"');
129
+ execCommand(`git commit -m "${escapedMessage}"`, cwd);
130
+ return true;
131
+ } catch (err) {
132
+ setError("Failed to commit changes");
133
+ return false;
134
+ } finally {
135
+ setLoading(false);
136
+ }
137
+ };
138
+ return {
139
+ getGitStatus,
140
+ stageFiles,
141
+ commit,
142
+ loading,
143
+ error,
144
+ clearError: () => setError(null)
145
+ };
146
+ }
147
+
148
+ function GitCommit({ context, messageArgs, onExit }) {
149
+ const { getGitStatus, stageFiles, commit, loading, error } = useGitOperations(context.cwd);
150
+ const [step, setStep] = useState("loading");
151
+ const [gitStatus, setGitStatus] = useState(null);
152
+ const [commitMessage, setCommitMessage] = useState(messageArgs?.join(" ") || "");
153
+ const [userInput, setUserInput] = useState("");
154
+ const [waitingForInput, setWaitingForInput] = useState(false);
155
+ useEffect(() => {
156
+ async function init() {
157
+ const status = await getGitStatus();
158
+ if (status) {
159
+ setGitStatus(status);
160
+ if (status.staged.length === 0 && status.unstaged.length === 0 && status.untracked.length === 0) {
161
+ setStep("success");
162
+ } else if (status.staged.length === 0) {
163
+ setStep("staging");
164
+ setWaitingForInput(true);
165
+ } else if (messageArgs && messageArgs.length > 0) {
166
+ setStep("commit");
167
+ setCommitMessage(messageArgs.join(" "));
74
168
  } else {
75
- consola.info(`Use ${colors.cyan("git add <file>...")} to stage specific files, then run the commit command again`);
76
- return;
169
+ setStep("message");
170
+ setWaitingForInput(true);
77
171
  }
78
172
  } else {
79
- consola.error(`No staged changes found to commit. Use ${colors.cyan("git add <file>...")} to stage changes before committing.`);
80
- process.exit(1);
173
+ setStep("error");
81
174
  }
82
- } else {
83
- consola.error("No changes to commit");
175
+ }
176
+ init();
177
+ }, []);
178
+ useInput((input, key) => {
179
+ if (key.escape) {
180
+ onExit();
84
181
  return;
85
182
  }
183
+ if (step === "staging" && waitingForInput) {
184
+ if (input === "y" || input === "Y" || key.return) {
185
+ handleStageFiles();
186
+ } else if (input === "n" || input === "N") {
187
+ onExit();
188
+ }
189
+ } else if (step === "message" && waitingForInput) {
190
+ if (key.return && userInput.trim()) {
191
+ setCommitMessage(userInput.trim());
192
+ setStep("commit");
193
+ setWaitingForInput(false);
194
+ } else if (key.backspace || key.delete) {
195
+ setUserInput((prev) => prev.slice(0, -1));
196
+ } else if (input && !key.ctrl && !key.meta) {
197
+ setUserInput((prev) => prev + input);
198
+ }
199
+ }
200
+ });
201
+ const handleStageFiles = async () => {
202
+ setWaitingForInput(false);
203
+ const success = await stageFiles(["."]);
204
+ if (success) {
205
+ const newStatus = await getGitStatus();
206
+ if (newStatus) {
207
+ setGitStatus(newStatus);
208
+ if (messageArgs && messageArgs.length > 0) {
209
+ setStep("commit");
210
+ } else {
211
+ setStep("message");
212
+ setWaitingForInput(true);
213
+ }
214
+ }
215
+ } else {
216
+ setStep("error");
217
+ }
218
+ };
219
+ useEffect(() => {
220
+ if (step === "commit" && commitMessage && !waitingForInput) {
221
+ async function performCommit() {
222
+ const success = await commit(commitMessage);
223
+ if (success) {
224
+ setStep("success");
225
+ } else {
226
+ setStep("error");
227
+ }
228
+ }
229
+ performCommit();
230
+ }
231
+ }, [step, commitMessage, waitingForInput]);
232
+ if (step === "loading" || loading) {
233
+ return /* @__PURE__ */ React.createElement(Box, null, /* @__PURE__ */ React.createElement(Text, null, "Loading git status..."));
86
234
  }
87
- let commitMessage;
88
- if (messageArgs && messageArgs.length > 0) {
89
- commitMessage = messageArgs.join(" ");
90
- } else {
91
- const { message } = await prompts({
92
- type: "text",
93
- name: "message",
94
- message: "Enter commit message:",
95
- validate: (value) => value.trim().length > 0 || "Commit message cannot be empty"
96
- });
97
- if (!message) {
98
- consola.info(colors.dim("Commit cancelled"));
99
- return;
235
+ if (step === "error" || error) {
236
+ return /* @__PURE__ */ React.createElement(Box, { flexDirection: "column" }, /* @__PURE__ */ React.createElement(Text, { color: "red" }, "Error: ", error || "An error occurred"), /* @__PURE__ */ React.createElement(Text, { color: "dim" }, "Press ESC to exit"));
237
+ }
238
+ if (step === "success") {
239
+ if (!gitStatus || gitStatus.staged.length === 0 && gitStatus.unstaged.length === 0 && gitStatus.untracked.length === 0) {
240
+ return /* @__PURE__ */ React.createElement(Box, { flexDirection: "column" }, /* @__PURE__ */ React.createElement(Text, { color: "green" }, "\u2713 Working tree clean - nothing to commit"), /* @__PURE__ */ React.createElement(Text, { color: "dim" }, "Press ESC to exit"));
241
+ } else {
242
+ return /* @__PURE__ */ React.createElement(Box, { flexDirection: "column" }, /* @__PURE__ */ React.createElement(Text, { color: "green" }, "\u2713 Commit created successfully!"), /* @__PURE__ */ React.createElement(Text, { color: "dim" }, "Press ESC to exit"));
100
243
  }
101
- commitMessage = message.trim();
102
244
  }
103
- const commandWithArgs = `git commit -m "${commitMessage.replace(/"/g, '\\"')}"`;
104
- consola.info(`Running: ${colors.cyan(commandWithArgs)}`);
105
- try {
106
- execCommand(commandWithArgs, config.cwd);
107
- consola.success("Commit created successfully!");
108
- } catch (error) {
109
- consola.error("Failed to commit changes:");
110
- consola.error(error);
111
- process.exit(1);
245
+ return /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", padding: 1 }, /* @__PURE__ */ React.createElement(Text, { bold: true, color: "cyan" }, "Git Commit"), gitStatus && /* @__PURE__ */ React.createElement(Box, { marginTop: 1, flexDirection: "column" }, /* @__PURE__ */ React.createElement(Text, { bold: true }, "Current Status:"), gitStatus.staged.length > 0 && /* @__PURE__ */ React.createElement(Box, { marginTop: 1, flexDirection: "column" }, /* @__PURE__ */ React.createElement(Text, { color: "green" }, "\u2713 Staged files (", gitStatus.staged.length, "):"), gitStatus.staged.slice(0, 5).map((file) => /* @__PURE__ */ React.createElement(Text, { key: file, color: "green" }, " ", file)), gitStatus.staged.length > 5 && /* @__PURE__ */ React.createElement(Text, { color: "green" }, " ... and ", gitStatus.staged.length - 5, " more")), gitStatus.unstaged.length > 0 && /* @__PURE__ */ React.createElement(Box, { marginTop: 1, flexDirection: "column" }, /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "M Modified files (", gitStatus.unstaged.length, "):"), gitStatus.unstaged.slice(0, 3).map((file) => /* @__PURE__ */ React.createElement(Text, { key: file, color: "yellow" }, " ", file)), gitStatus.unstaged.length > 3 && /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, " ... and ", gitStatus.unstaged.length - 3, " more")), gitStatus.untracked.length > 0 && /* @__PURE__ */ React.createElement(Box, { marginTop: 1, flexDirection: "column" }, /* @__PURE__ */ React.createElement(Text, { color: "red" }, "? Untracked files (", gitStatus.untracked.length, "):"), gitStatus.untracked.slice(0, 3).map((file) => /* @__PURE__ */ React.createElement(Text, { key: file, color: "red" }, " ", file)), gitStatus.untracked.length > 3 && /* @__PURE__ */ React.createElement(Text, { color: "red" }, " ... and ", gitStatus.untracked.length - 3, " more"))), /* @__PURE__ */ React.createElement(Box, { marginTop: 2, flexDirection: "column" }, step === "staging" && waitingForInput && /* @__PURE__ */ React.createElement(Box, { flexDirection: "column" }, /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "No files are staged. Add all modified and untracked files? (y/N)"), /* @__PURE__ */ React.createElement(Text, { color: "dim" }, "Press y for yes, n for no, or ESC to cancel")), step === "message" && waitingForInput && /* @__PURE__ */ React.createElement(Box, { flexDirection: "column" }, /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "Enter commit message:"), /* @__PURE__ */ React.createElement(Box, null, /* @__PURE__ */ React.createElement(Text, { color: "cyan" }, "> "), /* @__PURE__ */ React.createElement(Text, null, userInput), /* @__PURE__ */ React.createElement(Text, { color: "dim" }, "\u2588")), /* @__PURE__ */ React.createElement(Text, { color: "dim" }, "Press Enter to commit, ESC to cancel")), step === "commit" && /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "Committing changes...")), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { color: "dim" }, "Press ESC to cancel")));
246
+ }
247
+
248
+ function ConfigDisplay({ context }) {
249
+ return /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", padding: 1 }, /* @__PURE__ */ React.createElement(Text, { bold: true, color: "green" }, "Configuration"), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { color: "cyan" }, "Settings File: "), /* @__PURE__ */ React.createElement(Text, null, context.settingsFile.path)), /* @__PURE__ */ React.createElement(Box, { marginTop: 1, flexDirection: "column" }, /* @__PURE__ */ React.createElement(Text, { bold: true, color: "yellow" }, "User Config:"), /* @__PURE__ */ React.createElement(Box, { marginLeft: 2, marginTop: 1, flexDirection: "column" }, /* @__PURE__ */ React.createElement(Box, null, /* @__PURE__ */ React.createElement(Text, { color: "cyan" }, "Daily Path Template: "), /* @__PURE__ */ React.createElement(Text, null, context.settingsFile.settings.journalSettings.dailyPathTemplate)), /* @__PURE__ */ React.createElement(Box, null, /* @__PURE__ */ React.createElement(Text, { color: "cyan" }, "Meeting Path Template: "), /* @__PURE__ */ React.createElement(Text, null, context.settingsFile.settings.journalSettings.meetingPathTemplate)), /* @__PURE__ */ React.createElement(Box, null, /* @__PURE__ */ React.createElement(Text, { color: "cyan" }, "Note Path Template: "), /* @__PURE__ */ React.createElement(Text, null, context.settingsFile.settings.journalSettings.notePathTemplate)), /* @__PURE__ */ React.createElement(Box, null, /* @__PURE__ */ React.createElement(Text, { color: "cyan" }, "Editor: "), /* @__PURE__ */ React.createElement(Text, null, context.settingsFile.settings.preferredEditor)))), /* @__PURE__ */ React.createElement(Box, { marginTop: 1, flexDirection: "column" }, /* @__PURE__ */ React.createElement(Text, { bold: true, color: "yellow" }, "Working Directory:"), /* @__PURE__ */ React.createElement(Box, { marginLeft: 2, marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, null, context.cwd))));
250
+ }
251
+
252
+ function useTerminalSize() {
253
+ const [size, setSize] = useState({
254
+ columns: process.stdout.columns || 80,
255
+ rows: process.stdout.rows || 24
256
+ });
257
+ useEffect(() => {
258
+ const updateSize = () => {
259
+ setSize({
260
+ columns: process.stdout.columns || 80,
261
+ rows: process.stdout.rows || 24
262
+ });
263
+ };
264
+ process.stdout.on("resize", updateSize);
265
+ return () => {
266
+ process.stdout.off("resize", updateSize);
267
+ };
268
+ }, []);
269
+ return size;
270
+ }
271
+
272
+ function AppContent({ context, command, commandArgs }) {
273
+ const [isExiting, setIsExiting] = useState(false);
274
+ const terminalSize = useTerminalSize();
275
+ const handleExit = (code = 0) => {
276
+ setIsExiting(true);
277
+ setTimeout(() => process.exit(code), 100);
278
+ };
279
+ if (isExiting) {
280
+ return null;
112
281
  }
282
+ if (command === "git-commit") {
283
+ return /* @__PURE__ */ React.createElement(
284
+ GitCommit,
285
+ {
286
+ context,
287
+ messageArgs: commandArgs,
288
+ onExit: handleExit
289
+ }
290
+ );
291
+ }
292
+ if (command === "config") {
293
+ return /* @__PURE__ */ React.createElement(ConfigDisplay, { context });
294
+ }
295
+ return /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", padding: 1 }, /* @__PURE__ */ React.createElement(Text, { bold: true, color: DEFAULT_THEME.primary }, "Towles Tool"), /* @__PURE__ */ React.createElement(Text, { color: DEFAULT_THEME.dim }, "Terminal size: ", terminalSize.columns, "x", terminalSize.rows), /* @__PURE__ */ React.createElement(Text, { color: DEFAULT_THEME.warning }, "No command specified"));
296
+ }
297
+ function App(props) {
298
+ return /* @__PURE__ */ React.createElement(ErrorBoundary, null, /* @__PURE__ */ React.createElement(AppProvider, null, /* @__PURE__ */ React.createElement(ConfigProvider, { context: props.context }, /* @__PURE__ */ React.createElement(AppContent, { ...props }))));
299
+ }
300
+ function renderApp(props) {
301
+ return render(/* @__PURE__ */ React.createElement(App, { ...props }));
302
+ }
303
+
304
+ async function gitCommitCommand(context, messageArgs) {
305
+ const { waitUntilExit } = renderApp({
306
+ context,
307
+ command: "git-commit",
308
+ commandArgs: messageArgs
309
+ });
310
+ await waitUntilExit();
113
311
  }
114
312
 
115
313
  function getMondayOfWeek(date) {
@@ -214,44 +412,71 @@ async function openInEditor({ editor, filePath }) {
214
412
  consola.warn(`Could not open in editor : '${editor}'. Modify your editor in the config: examples include 'code', 'code-insiders', etc...`, ex);
215
413
  }
216
414
  }
217
- function generateJournalFileInfoByType({ date = /* @__PURE__ */ new Date(), type = JOURNAL_TYPES.DAILY_NOTES, title }) {
415
+ function resolvePathTemplate(template, title, date) {
416
+ const dateTime = DateTime.fromJSDate(date, { zone: "utc" });
417
+ return template.replace(/\{([^}]+)\}/g, (match, token) => {
418
+ try {
419
+ if (token === "title") {
420
+ return title.toLowerCase().replace(/\s+/g, "-");
421
+ }
422
+ const result = dateTime.toFormat(token);
423
+ const isLikelyInvalid = token.includes("invalid") || result.length > 20 || // Very long results are likely garbage
424
+ result.length > token.length * 2 && /\d{10,}/.test(result) || // Contains very long numbers
425
+ result.includes("UTC");
426
+ if (isLikelyInvalid) {
427
+ consola.warn(`Invalid date format token: ${token}`);
428
+ return match;
429
+ }
430
+ return result;
431
+ } catch (error) {
432
+ consola.warn(`Invalid date format token: ${token}`);
433
+ return match;
434
+ }
435
+ });
436
+ }
437
+ function generateJournalFileInfoByType({ journalSettings, date = /* @__PURE__ */ new Date(), type = JOURNAL_TYPES.DAILY_NOTES, title }) {
218
438
  const currentDate = new Date(date);
219
- const year = currentDate.getFullYear().toString();
439
+ let templatePath = "";
440
+ let mondayDate = getMondayOfWeek(currentDate);
220
441
  switch (type) {
221
442
  case JOURNAL_TYPES.DAILY_NOTES: {
222
443
  const monday = getMondayOfWeek(currentDate);
223
- const fileName = `${formatDate(monday)}-week-log.md`;
224
- const pathPrefix = [year, "daily-notes"];
225
- return { pathPrefix, fileName, mondayDate: monday };
444
+ templatePath = journalSettings.dailyPathTemplate;
445
+ mondayDate = monday;
446
+ break;
226
447
  }
227
448
  case JOURNAL_TYPES.MEETING: {
228
- const dateStr = formatDate(currentDate);
229
- const timeStr = currentDate.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" }).replace(":", "");
230
- const titleSlug = title ? `-${title.toLowerCase().replace(/\s+/g, "-")}` : "";
231
- const fileName = `${dateStr}-${timeStr}-meeting${titleSlug}.md`;
232
- const pathPrefix = [year, "meetings"];
233
- return { pathPrefix, fileName, mondayDate: currentDate };
449
+ templatePath = journalSettings.meetingPathTemplate;
450
+ mondayDate = currentDate;
451
+ break;
234
452
  }
235
453
  case JOURNAL_TYPES.NOTE: {
236
- const dateStr = formatDate(currentDate);
237
- const timeStr = currentDate.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" }).replace(":", "");
238
- const titleSlug = title ? `-${title.toLowerCase().replace(/\s+/g, "-")}` : "";
239
- const fileName = `${dateStr}-${timeStr}-note${titleSlug}.md`;
240
- const pathPrefix = [year, "notes"];
241
- return { pathPrefix, fileName, mondayDate: currentDate };
454
+ templatePath = journalSettings.notePathTemplate;
455
+ mondayDate = currentDate;
456
+ break;
242
457
  }
243
458
  default:
244
459
  throw new Error(`Unknown journal type: ${type}`);
245
460
  }
461
+ const resolvedPath = resolvePathTemplate(templatePath, title, currentDate);
462
+ return {
463
+ currentDate,
464
+ fullPath: resolvedPath,
465
+ mondayDate
466
+ };
246
467
  }
247
- async function createJournalFile({ userConfig, type, title }) {
468
+ async function createJournalFile({ context, type, title }) {
248
469
  try {
249
470
  const currentDate = /* @__PURE__ */ new Date();
250
- const fileInfo = generateJournalFileInfoByType({ date: currentDate, type, title });
251
- const filePath = path__default.join(userConfig.journalDir, ...fileInfo.pathPrefix, fileInfo.fileName);
252
- ensureDirectoryExists(path__default.join(userConfig.journalDir, ...fileInfo.pathPrefix));
253
- if (existsSync(filePath)) {
254
- consola.info(`Opening existing ${type} file: ${colors.cyan(filePath)}`);
471
+ const fileInfo = generateJournalFileInfoByType({
472
+ journalSettings: context.settingsFile.settings.journalSettings,
473
+ date: currentDate,
474
+ type,
475
+ title
476
+ });
477
+ ensureDirectoryExists(path__default.dirname(fileInfo.fullPath));
478
+ if (existsSync(fileInfo.fullPath)) {
479
+ consola.info(`Opening existing ${type} file: ${colors.cyan(fileInfo.fullPath)}`);
255
480
  } else {
256
481
  let content;
257
482
  switch (type) {
@@ -267,122 +492,197 @@ async function createJournalFile({ userConfig, type, title }) {
267
492
  default:
268
493
  throw new Error(`Unknown journal type: ${type}`);
269
494
  }
270
- writeFileSync(filePath, content, "utf8");
271
- consola.info(`Created new ${type} file: ${colors.cyan(filePath)}`);
495
+ consola.info(`Creating new ${type} file: ${colors.cyan(fileInfo.fullPath)}`);
496
+ writeFileSync(fileInfo.fullPath, content, "utf8");
272
497
  }
273
- await openInEditor({ editor: userConfig.editor, filePath });
498
+ await openInEditor({ editor: context.settingsFile.settings.preferredEditor, filePath: fileInfo.fullPath });
274
499
  } catch (error) {
275
500
  consola.warn(`Error creating ${type} file:`, error);
276
- process.exit(1);
501
+ process$1.exit(1);
277
502
  }
278
503
  }
279
504
 
280
- const constants = {
281
- toolName: "towles-tool"
282
- };
505
+ async function loadTowlesToolContext({
506
+ cwd,
507
+ settingsFile,
508
+ debug = false
509
+ }) {
510
+ return {
511
+ cwd,
512
+ settingsFile,
513
+ args: [],
514
+ // TODO: Load args from yargs
515
+ debug
516
+ };
517
+ }
283
518
 
284
- function printJson(obj) {
285
- consola.log(util.inspect(obj, {
286
- depth: 2,
287
- colors: true,
288
- showHidden: false,
289
- compact: false
290
- }));
519
+ async function configCommand(context) {
520
+ const { waitUntilExit } = renderApp({
521
+ context,
522
+ command: "config"
523
+ });
524
+ await waitUntilExit();
291
525
  }
292
- function printDebug(message, ...args) {
293
- if (process.env.DEBUG) {
294
- consola.debug(`DEBUG: ${message}`, ...args);
526
+
527
+ const USER_SETTINGS_DIR = path.join(homedir(), ".config", AppInfo.toolName);
528
+ const USER_SETTINGS_PATH = path.join(USER_SETTINGS_DIR, `${AppInfo.toolName}.settings.json`);
529
+ const JournalSettingsSchema = z.object({
530
+ // https://moment.github.io/luxon/#/formatting?id=table-of-tokens
531
+ dailyPathTemplate: z.string().default(path.join(homedir(), "journal", "{yyyy}/{MM}/daily-notes/{yyyy}-{MM}-{dd}-daily-notes.md")),
532
+ meetingPathTemplate: z.string().default(path.join(homedir(), "journal", "{yyyy}/{MM}/meetings/{yyyy}-{MM}-{dd}-{title}.md")),
533
+ notePathTemplate: z.string().default(path.join(homedir(), "journal", "{yyyy}/{MM}/notes/{yyyy}-{MM}-{dd}-{title}.md"))
534
+ });
535
+ const UserSettingsSchema = z.object({
536
+ preferredEditor: z.string().default("code"),
537
+ journalSettings: JournalSettingsSchema
538
+ });
539
+ class LoadedSettings {
540
+ constructor(settingsFile) {
541
+ this.settingsFile = settingsFile;
295
542
  }
543
+ settingsFile;
296
544
  }
297
-
298
- function getDefaultUserConfig() {
299
- return {
300
- journalDir: path.join(homedir(), "journal"),
301
- editor: "code"
545
+ function createSettingsFile() {
546
+ const defaultSettings = UserSettingsSchema.parse({
547
+ // its odd, but but we have to get default value from each schema object, if we don't it failes.
548
+ // https://github.com/colinhacks/zod/discussions/1953
549
+ journalSettings: JournalSettingsSchema.parse({})
550
+ });
551
+ const settingsFile = {
552
+ path: USER_SETTINGS_PATH,
553
+ settings: defaultSettings
302
554
  };
555
+ saveSettings(settingsFile);
556
+ consola.success(`Created settings file: ${USER_SETTINGS_PATH}`);
557
+ return defaultSettings;
303
558
  }
304
- async function loadTowlesToolConfig({
305
- cwd,
306
- overrides
307
- }) {
308
- await setupDotenv({ cwd });
309
- const defaults = getDefaultUserConfig();
310
- const defaultConfigFolder = path.join(homedir(), ".config", constants.toolName);
311
- const updateResult = await updateConfig({
312
- cwd: defaultConfigFolder,
313
- configFile: `${constants.toolName}.config`,
314
- createExtension: ".ts",
315
- async onCreate({ configFile: configFile2 }) {
316
- const shallCreate = await consola.prompt(
317
- `Do you want to initialize a new config in ${colors.cyan(configFile2)}?`,
318
- {
319
- type: "confirm",
320
- default: true
321
- }
322
- );
323
- if (shallCreate !== true) {
324
- return false;
325
- }
326
- return `
327
- // Default configuration for Towles Tool
328
- // You can customize these values to fit your needs
329
- // cwd: null means it will use the current working directory
330
- export default ${JSON.stringify(defaults, null, 2)};
331
- `;
332
- },
333
- async onUpdate(config) {
334
- return config;
559
+ function saveSettings(settingsFile) {
560
+ try {
561
+ const dirPath = path.dirname(settingsFile.path);
562
+ if (!fs.existsSync(dirPath)) {
563
+ fs.mkdirSync(dirPath, { recursive: true });
335
564
  }
336
- }).catch((error) => {
337
- consola.error(`Failed to update config: ${error.message}`);
338
- return null;
339
- });
340
- if (!updateResult?.configFile) {
341
- consola.error(`Failed to load or update config. Please check your configuration.`);
342
- process.exit(1);
565
+ fs.writeFileSync(
566
+ settingsFile.path,
567
+ JSON.stringify(settingsFile.settings, null, 2),
568
+ "utf-8"
569
+ );
570
+ } catch (error) {
571
+ consola.error("Error saving user settings file:", error);
343
572
  }
344
- const { config: userConfig, configFile } = await loadConfig({
345
- cwd: cwd || defaultConfigFolder,
346
- configFile: updateResult.configFile,
347
- name: constants.toolName,
348
- packageJson: true,
349
- defaults,
350
- overrides: {
351
- // cwd,
352
- ...overrides
573
+ }
574
+ async function loadSettings() {
575
+ let userSettings = null;
576
+ if (fs.existsSync(USER_SETTINGS_PATH)) {
577
+ const userContent = fs.readFileSync(USER_SETTINGS_PATH, "utf-8");
578
+ const parsedUserSettings = JSON.parse(stripJsonComments(userContent));
579
+ userSettings = UserSettingsSchema.parse(parsedUserSettings);
580
+ if (JSON.stringify(parsedUserSettings) !== JSON.stringify(userSettings)) {
581
+ consola.warn(`Settings file ${USER_SETTINGS_PATH} has been updated with default values.`);
582
+ const tempSettingsFile = {
583
+ path: USER_SETTINGS_PATH,
584
+ settings: userSettings
585
+ };
586
+ saveSettings(tempSettingsFile);
353
587
  }
354
- });
355
- printDebug(`Using config from: ${colors.cyan(configFile)}`);
356
- return {
357
- configFile,
358
- cwd,
359
- userConfig
360
- };
588
+ } else {
589
+ const confirmed = await consola.prompt(`Settings file not found. Create ${colors.cyan(USER_SETTINGS_PATH)}?`, {
590
+ type: "confirm"
591
+ });
592
+ if (!confirmed) {
593
+ throw new Error(`Settings file not found and user chose not to create it.`);
594
+ }
595
+ userSettings = createSettingsFile();
596
+ }
597
+ return new LoadedSettings(
598
+ {
599
+ path: USER_SETTINGS_PATH,
600
+ settings: userSettings
601
+ }
602
+ );
361
603
  }
362
604
 
363
605
  async function main() {
364
- const consola$1 = consola.withTag(constants.toolName);
365
- const config = await loadTowlesToolConfig({ cwd: process.cwd() });
366
- const program = new Command();
367
- program.name(constants.toolName).description("One off quality of life scripts that I use on a daily basis").version(version);
368
- const journalCmd = program.command("journal").description("quickly create md files from templates files like daily-notes, meeting, notes, etc.");
369
- journalCmd.command("daily-notes").description("Weekly files with daily sections for ongoing work and notes").action(async () => {
370
- await createJournalFile({ userConfig: config.userConfig, type: JOURNAL_TYPES.DAILY_NOTES });
371
- });
372
- journalCmd.command("meeting [title]").description("Structured meeting notes with agenda and action items").action(async (title) => {
373
- await createJournalFile({ userConfig: config.userConfig, type: JOURNAL_TYPES.MEETING, title });
374
- });
375
- journalCmd.command("note [title]").description("General-purpose notes with structured sections").action(async (title) => {
376
- await createJournalFile({ userConfig: config.userConfig, type: JOURNAL_TYPES.NOTE, title });
377
- });
378
- program.command("git-commit [message...]").alias("gc").description("Git commit command with optional message").action(async (message) => {
379
- await gitCommitCommand(config, message);
606
+ const settings = await loadSettings();
607
+ const context = await loadTowlesToolContext({
608
+ cwd: process$1.cwd(),
609
+ settingsFile: settings.settingsFile,
610
+ debug: true
611
+ // later can be set to false in production or when not debugging
380
612
  });
381
- program.command("config").description("set or show configuration file.").action(async () => {
382
- consola$1.log(colors.green("Showing configuration..."));
383
- consola$1.log("Settings File:", config.configFile);
384
- printJson(config);
385
- });
386
- program.parse();
613
+ consola.info(`Using configuration from ${settings.settingsFile.path}`);
614
+ const parser = yargs(hideBin(process$1.argv)).scriptName(AppInfo.toolName).usage("Usage: $0 <command> [options]").version(version).demandCommand(1, "You need at least one command").recommendCommands().strict().help().wrap(yargs().terminalWidth());
615
+ parser.command(
616
+ ["journal", "j"],
617
+ "quickly create md files from templates files like daily-notes, meeting, notes, etc.",
618
+ (yargs2) => {
619
+ return yargs2.command(
620
+ ["daily-notes", "today"],
621
+ "Weekly files with daily sections for ongoing work and notes",
622
+ {},
623
+ async () => {
624
+ await createJournalFile({ context, type: JOURNAL_TYPES.DAILY_NOTES, title: "" });
625
+ }
626
+ ).command(
627
+ ["meeting [title]", "m"],
628
+ "Structured meeting notes with agenda and action items",
629
+ (yargs3) => {
630
+ return yargs3.positional("title", {
631
+ type: "string",
632
+ describe: "Meeting title"
633
+ });
634
+ },
635
+ async (argv) => {
636
+ await createJournalFile({ context, type: JOURNAL_TYPES.MEETING, title: argv.title || "" });
637
+ }
638
+ ).command(
639
+ ["note [title]", "n"],
640
+ "General-purpose notes with structured sections",
641
+ (yargs3) => {
642
+ return yargs3.positional("title", {
643
+ type: "string",
644
+ describe: "Note title"
645
+ });
646
+ },
647
+ async (argv) => {
648
+ await createJournalFile({ context, type: JOURNAL_TYPES.NOTE, title: argv.title || "" });
649
+ }
650
+ ).demandCommand(1, "You need to specify a journal subcommand").help();
651
+ },
652
+ () => {
653
+ parser.showHelp();
654
+ }
655
+ );
656
+ parser.command(
657
+ ["git-commit [message...]", "gc"],
658
+ "Git commit command with optional message",
659
+ (yargs2) => {
660
+ return yargs2.positional("message", {
661
+ type: "string",
662
+ array: true,
663
+ describe: "Commit message words"
664
+ });
665
+ },
666
+ async (argv) => {
667
+ await gitCommitCommand(context, argv.message || []);
668
+ }
669
+ );
670
+ parser.command(
671
+ ["config", "cfg"],
672
+ "set or show configuration file.",
673
+ {},
674
+ async () => {
675
+ await configCommand(context);
676
+ }
677
+ );
678
+ await parser.parse();
387
679
  }
388
- await main();
680
+ main().catch((error) => {
681
+ consola.error("An unexpected critical error occurred:");
682
+ if (error instanceof Error) {
683
+ consola.error(error.stack);
684
+ } else {
685
+ consola.error(String(error));
686
+ }
687
+ process$1.exit(1);
688
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@towles/tool",
3
3
  "type": "module",
4
- "version": "0.0.11",
4
+ "version": "0.0.12",
5
5
  "description": "One off quality of life scripts that I use on a daily basis.",
6
6
  "author": "Chris Towles <Chris.Towles.Dev@gmail.com>",
7
7
  "license": "MIT",
@@ -36,22 +36,31 @@
36
36
  "@anthropic-ai/claude-code": "^1.0.51",
37
37
  "@anthropic-ai/sdk": "^0.56.0",
38
38
  "@clack/prompts": "^0.11.0",
39
+ "@inkjs/ui": "^2.0.0",
39
40
  "c12": "^3.0.4",
40
41
  "changelogen": "^0.6.2",
41
- "commander": "^14.0.0",
42
42
  "consola": "^3.4.2",
43
43
  "fzf": "^0.5.2",
44
+ "ink": "^5.0.1",
45
+ "luxon": "^3.7.1",
44
46
  "magicast": "^0.3.5",
45
47
  "neverthrow": "^8.2.0",
46
48
  "prompts": "^2.4.2",
49
+ "react": "^18.3.1",
50
+ "strip-json-comments": "^5.0.2",
51
+ "yargs": "^17.7.2",
47
52
  "zod": "^4.0.5"
48
53
  },
49
54
  "devDependencies": {
50
55
  "@antfu/ni": "^25.0.0",
51
56
  "@antfu/utils": "^9.2.0",
57
+ "@types/luxon": "^3.6.2",
52
58
  "@types/node": "^22.16.3",
53
59
  "@types/prompts": "^2.4.9",
60
+ "@types/react": "^18.3.12",
61
+ "@types/yargs": "^17.0.32",
54
62
  "bumpp": "^10.2.0",
63
+ "ink-testing-library": "^4.0.0",
55
64
  "lint-staged": "^15.5.2",
56
65
  "oxlint": "^1.7.0",
57
66
  "simple-git-hooks": "^2.13.0",
@@ -81,9 +90,9 @@
81
90
  "lint:fix_all": "oxlint --fix .",
82
91
  "release:local": "bumpp && pnpm publish --no-git-checks -r --access public",
83
92
  "release": "bumpp && echo \"github action will run and publish to npm\"",
84
- "start": "tsx src/index.ts",
93
+ "start": "tsx src/index.tsx",
85
94
  "test": "vitest --run",
86
- "test:watch": "vitest",
95
+ "test:watch": "CI=DisableCallingClaude vitest --watch",
87
96
  "typecheck": "tsc --noEmit"
88
97
  }
89
98
  }