niahere 0.2.51 → 0.2.52

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.
package/README.md CHANGED
@@ -34,51 +34,63 @@ nia start # starts daemon + registers OS service
34
34
  - **Slack** — Socket Mode bot with thread awareness, thinking emoji, watch channels for proactive monitoring
35
35
  - **Terminal chat** — REPL with session resume support
36
36
  - **Scheduled jobs** — recurring jobs and crons that run Claude and can message you back
37
- - **Persona system** — customizable identity, soul, owner profile, and on-demand memory
37
+ - **Persona system** — customizable identity, soul, owner profile, rules, and memory (preloaded every session)
38
38
  - **Agents** — domain specialists (marketer, senior-dev) via Claude Agent SDK subagents
39
39
  - **Skills** — loads skills from multiple directories, invokable as slash commands
40
40
  - **Cross-platform service** — launchd (macOS), systemd (Linux), service-aware restart
41
- - **MCP tools** — 18 tools for job management, messaging, memory, and channel control
41
+ - **MCP tools** — 20 tools for job management, messaging, memory, rules, and channel control
42
+ - **Background memory consolidation** — extracts memories from conversations and job runs automatically
43
+ - **Session summaries** — handoff notes between sessions for continuity
44
+ - **Backups** — `nia backup` with auto-backup before updates
42
45
  - **Optional integrations** — add Gmail, Discord, and more via skills
43
46
 
44
47
  ## Commands
45
48
 
46
49
  ```
47
- nia init — interactive setup (db, channels, persona, images)
50
+ nia init — interactive setup (db, channels, persona, agents, active hours)
48
51
  nia start / stop — daemon + OS service (launchd/systemd)
49
52
  nia restart — restart daemon (service-aware)
50
53
  nia status — show daemon, jobs, channels, chat rooms
51
- nia chat [-r|--resume] interactive terminal chat
54
+ nia health check daemon, db, channels, config
55
+ nia chat [-c|-r] [--channel ch] — terminal chat (new by default, -c continue, -r pick)
52
56
  nia run <prompt> — one-shot prompt execution
53
57
  nia history [room] — recent messages
54
- nia logs [-f] — daemon logs (follow with -f)
58
+ nia logs [-f] [--channel ch] — daemon logs (follow with -f, filter by channel)
55
59
  nia send [-c channel] <msg> — send a message via channel
56
- nia skills — list available skills
57
60
  nia version — show version
61
+ nia update — update to latest version (auto-backup + restart)
58
62
 
59
63
  nia job list — list all jobs
60
64
  nia job show [name] — full details + recent runs
61
- nia job add <n> <s> <p> add a job (active hours only)
62
- nia job add <n> <s> <p> --agent <name> — add a job using an agent
63
- nia job add <n> <s> <p> --always add a cron (runs 24/7)
65
+ nia job status [name] quick status check
66
+ nia job add <n> <s> <p> — add a job (--type, --always, --agent, --prompt-file)
67
+ nia job update <name> update a job (--schedule, --prompt, --prompt-file, --type, --always, --agent)
64
68
  nia job remove <name> — delete a job
65
69
  nia job enable / disable <n> — toggle a job
66
70
  nia job run <name> — run a job once
67
71
  nia job log [name] — show recent run history
68
72
 
73
+ nia rules [show|reset] — view or reset rules.md
74
+ nia memory [show|reset] — view or reset memory.md
69
75
  nia agent list — list available agents
70
76
  nia agent show <name> — show agent details and prompt
77
+ nia skills [source] — list available skills
71
78
 
72
- nia db setupinstall PostgreSQL + create database + migrate
73
- nia db migrate run database migrations
74
- nia db status check database connection
79
+ nia channelsshow channel status (on/off)
80
+ nia channels on / off enable/disable channels (applied via SIGHUP, no restart)
81
+ nia watch list list Slack watch channels
82
+ nia watch add/remove/enable/disable — manage watch channels
75
83
 
76
84
  nia config list — show all config
77
85
  nia config get <key> — get a config value (dot notation supported)
78
86
  nia config set <key> <value> — set a config value
87
+ nia validate — validate config.yaml
88
+ nia backup [list] — create or list backups
89
+ nia test [-v] — run tests
79
90
 
80
- nia channelsshow channel status (on/off)
81
- nia channels on / off — enable/disable channels
91
+ nia db setupinstall PostgreSQL + create database + migrate
92
+ nia db migrate — run database migrations
93
+ nia db status — check database connection
82
94
  ```
83
95
 
84
96
  ## Architecture
@@ -92,7 +104,8 @@ All config and data lives in `~/.niahere/`:
92
104
  identity.md — agent personality and voice
93
105
  owner.md — who runs this agent
94
106
  soul.md — how the agent works
95
- memory.md persistent learnings (read/written on demand)
107
+ rules.md behavioral instructions (loaded every session)
108
+ memory.md — persistent facts and context (loaded every session)
96
109
  images/
97
110
  reference.webp — visual identity reference image
98
111
  profile.webp — profile picture for Telegram/Slack
@@ -119,7 +132,7 @@ Users then run `/add-discord` on their fork and get clean code that does exactly
119
132
  ## Updating
120
133
 
121
134
  ```bash
122
- npm i -g niahere # pulls the latest version from npm
135
+ nia update # auto-backup, install latest, restart daemon
123
136
  ```
124
137
 
125
138
  ## Author
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "niahere",
3
- "version": "0.2.51",
4
- "description": "A personal AI assistant daemon — scheduled jobs, chat across Telegram and Slack, persona system, and visual identity.",
3
+ "version": "0.2.52",
4
+ "description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "start": "bun run src/cli/index.ts start",
package/src/cli/agent.ts CHANGED
@@ -1,8 +1,20 @@
1
1
  import { scanAgents } from "../core/agents";
2
+ import { fail } from "../utils/cli";
3
+
4
+ const HELP = `Usage: nia agent <command>
5
+
6
+ Commands:
7
+ list List all available agents
8
+ show <name> Show agent details and prompt`;
2
9
 
3
10
  export async function agentCommand(): Promise<void> {
4
11
  const subcommand = process.argv[3];
5
12
 
13
+ if (subcommand === "--help" || subcommand === "-h" || subcommand === "help") {
14
+ console.log(HELP);
15
+ return;
16
+ }
17
+
6
18
  switch (subcommand) {
7
19
  case "list": {
8
20
  const agents = scanAgents();
@@ -20,16 +32,10 @@ export async function agentCommand(): Promise<void> {
20
32
 
21
33
  case "show": {
22
34
  const name = process.argv[4];
23
- if (!name) {
24
- console.error("Usage: nia agent show <name>");
25
- process.exit(1);
26
- }
35
+ if (!name) fail("Usage: nia agent show <name>");
27
36
  const agents = scanAgents();
28
37
  const agent = agents.find((a) => a.name === name);
29
- if (!agent) {
30
- console.error(`Agent "${name}" not found.`);
31
- process.exit(1);
32
- }
38
+ if (!agent) fail(`Agent "${name}" not found.`);
33
39
  console.log(`Name: ${agent.name}`);
34
40
  console.log(`Description: ${agent.description}`);
35
41
  if (agent.model) console.log(`Model: ${agent.model}`);
@@ -40,8 +46,8 @@ export async function agentCommand(): Promise<void> {
40
46
  }
41
47
 
42
48
  default:
43
- console.log("Usage: nia agent <list|show>");
44
- console.log(" list List all available agents");
45
- console.log(" show <name> Show agent details and prompt");
49
+ if (subcommand) console.error(`Unknown subcommand: ${subcommand}`);
50
+ console.log(HELP);
51
+ process.exit(subcommand ? 1 : 0);
46
52
  }
47
53
  }
package/src/cli/index.ts CHANGED
@@ -29,7 +29,7 @@ try {
29
29
  const command = process.argv[2];
30
30
 
31
31
  // Ensure ~/.niahere/ exists for commands that need it
32
- if (command && !["init", "help", "version", "-v", "--version"].includes(command)) {
32
+ if (command && !["init", "help", "version", "-v", "--version", "-h", "--help"].includes(command)) {
33
33
  mkdirSync(getNiaHome(), { recursive: true });
34
34
  }
35
35
 
@@ -494,30 +494,54 @@ switch (command) {
494
494
  break;
495
495
  }
496
496
 
497
- default:
498
- console.log("Usage: nia <command>\n");
499
- console.log(" update — update to latest version and restart");
500
- console.log(" init — setup nia");
501
- console.log(" start / stop — daemon + service control");
502
- console.log(" restart — restart daemon");
503
- console.log(" status [--json --rooms N --all] — show daemon, jobs, channels");
504
- console.log(" health — check daemon, db, channels, config");
505
- console.log(" chat [--channel ch] interactive chat (--channel simulates a channel)");
506
- console.log(" run <prompt> — one-shot execution");
507
- console.log(" history [room] recent messages");
508
- console.log(" logs [-f] [--channel ch] — daemon logs (filter by channel)");
509
- console.log(" job <sub> — manage jobs");
510
- console.log(" rules [show|reset] — view or reset rules.md");
511
- console.log(" memory [show|reset] — view or reset memory.md");
512
- console.log(" db <sub> — database setup/status/migrate");
513
- console.log(" agent <sub> list/show agents");
514
- console.log(" skills — list available skills");
515
- console.log(" watch <sub> manage Slack watch channels");
516
- console.log(" validate — validate config.yaml");
517
- console.log(" config <sub> — get/set/list config values");
518
- console.log(" send [-c ch] <msg> send a message via channel");
519
- console.log(" telegram <token> — configure telegram");
520
- console.log(" slack <bot> <app> — configure slack");
521
- console.log(" test — run tests");
522
- process.exit(command ? 1 : 0);
497
+ case "help":
498
+ case "--help":
499
+ case "-h":
500
+ default: {
501
+ const HELP = `Usage: nia <command>
502
+
503
+ Daemon:
504
+ start Start daemon + register service
505
+ stop Stop daemon + unregister service
506
+ restart Restart daemon
507
+ status [--json --rooms N --all] Show daemon, jobs, channels
508
+ health Check daemon, db, channels, config
509
+ logs [-f] [--channel ch] Daemon logs (filter by channel)
510
+
511
+ Chat:
512
+ chat [-c] [-r] [--channel ch] Interactive chat (new session by default)
513
+ run <prompt> One-shot execution
514
+ history [room] Recent messages
515
+ send [-c ch] <msg> Send a message via channel
516
+
517
+ Jobs:
518
+ job <sub> Manage jobs (list|add|update|remove|run|...)
519
+
520
+ Persona:
521
+ rules [show|reset] View or reset rules.md
522
+ memory [show|reset] View or reset memory.md
523
+ agent <sub> List/show agents
524
+ skills [source] List available skills
525
+
526
+ Channels:
527
+ channels [on|off] Toggle channels
528
+ watch <sub> Manage Slack watch channels
529
+ telegram <token> Configure telegram
530
+ slack <bot> <app> Configure slack
531
+
532
+ System:
533
+ config <set|get|list> Manage config values
534
+ backup [list] Create or list backups
535
+ validate Validate config.yaml
536
+ db <sub> Database setup/status/migrate
537
+ update Update to latest version
538
+ init Initial setup
539
+ test [-v] Run tests`;
540
+
541
+ console.log(HELP);
542
+ // Unknown command → exit 1, help/no command → exit 0
543
+ const isHelp = !command || command === "help" || command === "--help" || command === "-h";
544
+ if (!isHelp) console.error(`\nUnknown command: ${command}`);
545
+ process.exit(isHelp ? 0 : 1);
546
+ }
523
547
  }
package/src/cli/job.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import * as readline from "readline";
2
- import { CronExpressionParser } from "cron-parser";
3
2
  import { readState, readAudit } from "../utils/logger";
4
3
  import { getConfig } from "../utils/config";
5
4
  import { runJob } from "../core/runner";
@@ -9,9 +8,34 @@ import { Job } from "../db/models";
9
8
  import { withDb } from "../db/connection";
10
9
  import type { ScheduleType } from "../types";
11
10
  import { errMsg } from "../utils/errors";
12
- import { fail, pickFromList, ICON_PASS, ICON_FAIL } from "../utils/cli";
11
+ import { fail, parseArgs, pickFromList, ICON_PASS, ICON_FAIL } from "../utils/cli";
13
12
  import { computeInitialNextRun } from "../core/scheduler";
14
13
 
14
+ const HELP = `Usage: nia job <command>
15
+
16
+ Commands:
17
+ list List all jobs
18
+ show [name] Full job details + recent runs
19
+ status [name] Quick status check
20
+ add <name> <schedule> <prompt> Add a job
21
+ --prompt <text> Prompt text (alternative to positional)
22
+ --prompt-file <path> Read prompt from file (for long/multi-line)
23
+ --type cron|interval|once Schedule type (default: cron)
24
+ --always Run 24/7 regardless of active hours
25
+ --agent <name> Assign an agent to the job
26
+ update <name> Update a job
27
+ --schedule <schedule> New schedule
28
+ --prompt <text> New prompt
29
+ --prompt-file <path> Read prompt from file (for long/multi-line)
30
+ --type cron|interval|once Change schedule type
31
+ --always / --no-always Toggle 24/7 mode
32
+ --agent <name> Assign agent (--no-agent to remove)
33
+ remove <name> Delete a job
34
+ enable <name> Enable a job
35
+ disable <name> Disable a job
36
+ run <name> Run a job once
37
+ log [name] Show recent run history`;
38
+
15
39
  async function pickJob(prompt = "Pick a job"): Promise<string> {
16
40
  let jobs: { name: string; schedule: string; enabled: boolean; prompt: string }[] = [];
17
41
  try {
@@ -39,6 +63,11 @@ async function pickJob(prompt = "Pick a job"): Promise<string> {
39
63
  export async function jobCommand(): Promise<void> {
40
64
  const subcommand = process.argv[3];
41
65
 
66
+ if (!subcommand || subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
67
+ console.log(HELP);
68
+ process.exit(subcommand ? 0 : 0);
69
+ }
70
+
42
71
  switch (subcommand) {
43
72
  case "list": {
44
73
  try {
@@ -62,42 +91,36 @@ export async function jobCommand(): Promise<void> {
62
91
  }
63
92
 
64
93
  case "add": {
65
- const always = process.argv.includes("--always");
66
- let cliArgs = process.argv.slice(4).filter((a) => a !== "--always");
67
-
68
- // Parse --type flag
69
- let scheduleType: ScheduleType = "cron";
70
- const typeIdx = cliArgs.indexOf("--type");
71
- if (typeIdx !== -1 && cliArgs[typeIdx + 1]) {
72
- const val = cliArgs[typeIdx + 1];
73
- if (val === "cron" || val === "interval" || val === "once") {
74
- scheduleType = val;
75
- cliArgs.splice(typeIdx, 2);
76
- }
77
- }
94
+ const args = parseArgs(process.argv.slice(4), ["always"]);
95
+ if (args.help) { console.log(HELP); return; }
78
96
 
79
- // Parse --agent flag
80
- let agent: string | undefined;
81
- const agentIdx = cliArgs.indexOf("--agent");
82
- if (agentIdx !== -1 && cliArgs[agentIdx + 1]) {
83
- agent = cliArgs[agentIdx + 1];
84
- cliArgs.splice(agentIdx, 2);
97
+ const scheduleType = (args.getString("type") || "cron") as ScheduleType;
98
+ if (!["cron", "interval", "once"].includes(scheduleType)) {
99
+ fail(`Invalid --type: "${scheduleType}". Must be cron, interval, or once.`);
85
100
  }
86
101
 
87
- const name = cliArgs[0];
88
- const schedule = cliArgs[1];
89
- const prompt = cliArgs.slice(2).join(" ");
102
+ const always = args.getBool("always") ?? false;
103
+ const agent = args.getString("agent");
104
+
105
+ const [name, schedule, ...promptParts] = args.positional;
106
+ const promptFlag = args.getString("prompt");
107
+ const promptFile = args.getString("prompt-file");
108
+ let prompt: string;
109
+ if (promptFile) {
110
+ const { existsSync, readFileSync } = await import("fs");
111
+ if (!existsSync(promptFile)) fail(`File not found: ${promptFile}`);
112
+ prompt = readFileSync(promptFile, "utf8").trim();
113
+ } else if (promptFlag) {
114
+ prompt = promptFlag;
115
+ } else {
116
+ prompt = promptParts.join(" ");
117
+ }
90
118
 
91
119
  if (!name || !schedule || !prompt) {
92
- console.log('Usage: nia job add <name> <schedule> <prompt> [--always] [--type cron|interval|once] [--agent <name>]');
120
+ console.error('Usage: nia job add <name> <schedule> <prompt> [--always] [--type cron|interval|once] [--agent <name>]');
93
121
  fail('Example: nia job add heartbeat "*/10 * * * *" Check system health --always');
94
122
  }
95
123
 
96
- // Validate schedule based on type
97
- if (scheduleType === "cron") {
98
- try { CronExpressionParser.parse(schedule); } catch { fail(`Invalid cron schedule: ${schedule}`); }
99
- }
100
-
101
124
  try {
102
125
  const config = getConfig();
103
126
  const nextRunAt = computeInitialNextRun(scheduleType, schedule, config.timezone);
@@ -144,29 +167,47 @@ export async function jobCommand(): Promise<void> {
144
167
  }
145
168
 
146
169
  case "update": {
147
- const hasAlways = process.argv.includes("--always");
148
- const hasNoAlways = process.argv.includes("--no-always");
149
- let cliArgs = process.argv.slice(4).filter((a) => a !== "--always" && a !== "--no-always");
170
+ const args = parseArgs(process.argv.slice(4), ["always"]);
171
+ if (args.help) { console.log(HELP); return; }
150
172
 
151
- const name = cliArgs[0];
173
+ const name = args.positional[0];
152
174
  if (!name) {
153
- console.log('Usage: nia job update <name> [--schedule <schedule>] [--prompt <prompt>] [--always] [--no-always]');
175
+ console.error('Usage: nia job update <name> [--schedule <s>] [--prompt <p>] [--type <t>] [--always] [--no-always]');
154
176
  fail('Example: nia job update curator --schedule "4h" --prompt "New prompt"');
155
177
  }
156
178
 
157
- const scheduleIdx = cliArgs.indexOf("--schedule");
158
- const schedule = scheduleIdx !== -1 ? cliArgs[scheduleIdx + 1] : undefined;
159
- const promptIdx = cliArgs.indexOf("--prompt");
160
- const prompt = promptIdx !== -1 ? cliArgs.slice(promptIdx + 1).filter((a) => a !== "--schedule" && a !== schedule).join(" ") : undefined;
179
+ const fields: Partial<{ schedule: string; prompt: string; always: boolean; scheduleType: ScheduleType; agent: string | null }> = {};
180
+ const schedule = args.getString("schedule");
181
+ const promptFile = args.getString("prompt-file");
182
+ let prompt = args.getString("prompt");
183
+ if (promptFile) {
184
+ const { existsSync, readFileSync } = await import("fs");
185
+ if (!existsSync(promptFile)) fail(`File not found: ${promptFile}`);
186
+ prompt = readFileSync(promptFile, "utf8").trim();
187
+ }
188
+ const scheduleType = args.getString("type") as ScheduleType | undefined;
189
+ const always = args.getBool("always");
190
+ const agent = args.getString("agent");
191
+ const noAgent = args.getBool("agent");
192
+
193
+ if (schedule) fields.schedule = schedule;
194
+ if (prompt) fields.prompt = prompt;
195
+ if (scheduleType) {
196
+ if (!["cron", "interval", "once"].includes(scheduleType)) {
197
+ fail(`Invalid --type: "${scheduleType}". Must be cron, interval, or once.`);
198
+ }
199
+ fields.scheduleType = scheduleType;
200
+ }
201
+ if (always !== undefined) fields.always = always;
202
+ if (agent) fields.agent = agent;
203
+ if (noAgent === false) fields.agent = null;
204
+
205
+ if (Object.keys(fields).length === 0) {
206
+ fail("Nothing to update. Pass at least one flag (--schedule, --prompt, --type, --always, --agent).");
207
+ }
161
208
 
162
209
  try {
163
210
  await withDb(async () => {
164
- const fields: Partial<{ schedule: string; prompt: string; always: boolean }> = {};
165
- if (schedule) fields.schedule = schedule;
166
- if (prompt) fields.prompt = prompt;
167
- if (hasAlways) fields.always = true;
168
- if (hasNoAlways) fields.always = false;
169
-
170
211
  const updated = await Job.update(name, fields);
171
212
  if (!updated) fail(`Job not found: "${name}". Use \`nia job list\` to see available jobs.`);
172
213
  console.log(`Job "${name}" updated.`);
@@ -186,9 +227,9 @@ export async function jobCommand(): Promise<void> {
186
227
  if (!job) fail(`Job not found: ${name}`);
187
228
 
188
229
  console.log(` ${job.enabled ? "●" : "○"} ${job.name}`);
189
- console.log(` schedule: ${job.schedule}`);
230
+ console.log(` schedule: ${job.schedule} (${job.scheduleType})`);
190
231
  console.log(` enabled: ${job.enabled}`);
191
- console.log(` type: ${job.always ? "cron (runs 24/7)" : "job (active hours only)"}`);
232
+ console.log(` always: ${job.always}`);
192
233
  if (job.agent) console.log(` agent: ${job.agent}`);
193
234
  console.log(` prompt: ${job.prompt}`);
194
235
 
@@ -313,20 +354,8 @@ export async function jobCommand(): Promise<void> {
313
354
  }
314
355
 
315
356
  default:
316
- console.log("Usage: nia job <list|show|status|add|update|remove|enable|disable|run|log|import>\n");
317
- console.log(" list — list all jobs");
318
- console.log(" show [name] — full job details + recent runs");
319
- console.log(" status [name] — quick status check");
320
- console.log(" add <name> <schedule> <prompt> — add a job (active hours only)")
321
- console.log(" --always — run 24/7 regardless of active hours");
322
- console.log(" --agent <name> — assign an agent to the job");
323
- console.log(" update <name> [--schedule s] [--prompt p] [--always] — update a job");
324
- console.log(" remove <name> — delete a job");
325
- console.log(" enable <name> — enable a job");
326
- console.log(" disable <name> — disable a job");
327
- console.log(" run <name> — run a job once");
328
- console.log(" log [name] — show recent run history");
329
- console.log(" import — import YAML jobs to DB");
330
- process.exit(subcommand ? 1 : 0);
357
+ console.error(`Unknown subcommand: ${subcommand}`);
358
+ console.log(HELP);
359
+ process.exit(1);
331
360
  }
332
361
  }
package/src/cli/watch.ts CHANGED
@@ -2,9 +2,23 @@ import { readRawConfig } from "../utils/config";
2
2
  import { addWatchChannel, removeWatchChannel, enableWatchChannel, disableWatchChannel } from "../mcp/tools";
3
3
  import { fail, ICON_PASS, ICON_FAIL } from "../utils/cli";
4
4
 
5
+ const HELP = `Usage: nia watch <command>
6
+
7
+ Commands:
8
+ list List watch channels (default)
9
+ add <channel_id#name> <behavior> Add a watch channel
10
+ remove <channel_id#name> Remove a watch channel
11
+ enable <channel_id#name> Enable a watch channel
12
+ disable <channel_id#name> Disable a watch channel`;
13
+
5
14
  export function watchCommand(): void {
6
15
  const sub = process.argv[3];
7
16
 
17
+ if (sub === "--help" || sub === "-h" || sub === "help") {
18
+ console.log(HELP);
19
+ return;
20
+ }
21
+
8
22
  switch (sub) {
9
23
  case "list":
10
24
  case undefined: {
@@ -60,12 +74,8 @@ export function watchCommand(): void {
60
74
  }
61
75
 
62
76
  default:
63
- console.log("Usage: nia watch <list|add|remove|enable|disable>\n");
64
- console.log(" list — list watch channels (default)");
65
- console.log(" add <channel_id#name> <behavior> — add a watch channel");
66
- console.log(" remove <channel_id#name> — remove a watch channel");
67
- console.log(" enable <channel_id#name> — enable a watch channel");
68
- console.log(" disable <channel_id#name> — disable a watch channel");
69
- process.exit(sub ? 1 : 0);
77
+ console.error(`Unknown subcommand: ${sub}`);
78
+ console.log(HELP);
79
+ process.exit(1);
70
80
  }
71
81
  }
@@ -65,6 +65,7 @@ function isWithinActiveHours(): boolean {
65
65
  }
66
66
 
67
67
  let timer: ReturnType<typeof setInterval> | null = null;
68
+ const runningJobs = new Set<string>();
68
69
 
69
70
  async function tick(): Promise<void> {
70
71
  let dueJobs: Awaited<ReturnType<typeof Job.listDue>>;
@@ -87,12 +88,20 @@ async function tick(): Promise<void> {
87
88
  continue;
88
89
  }
89
90
 
91
+ if (runningJobs.has(job.name)) {
92
+ log.info({ job: job.name }, "scheduler: skipping — still running from previous invocation");
93
+ continue;
94
+ }
95
+
90
96
  log.info({ job: job.name, type: job.scheduleType }, "scheduler: running job");
97
+ runningJobs.add(job.name);
91
98
 
92
99
  runJob(job).then((result) => {
93
100
  log.info({ job: job.name, status: result.status, duration: result.duration_ms }, "scheduler: job completed");
94
101
  }).catch((err) => {
95
102
  log.error({ err, job: job.name }, "scheduler: job failed");
103
+ }).finally(() => {
104
+ runningJobs.delete(job.name);
96
105
  });
97
106
 
98
107
  let nextRun: Date | null = null;
package/src/utils/cli.ts CHANGED
@@ -1,14 +1,17 @@
1
1
  import * as readline from "readline";
2
2
 
3
- // ANSI colors
4
- export const DIM = "\x1b[2m";
5
- export const BOLD = "\x1b[1m";
6
- export const RESET = "\x1b[0m";
7
- export const RED = "\x1b[31m";
8
- export const GREEN = "\x1b[32m";
9
- export const YELLOW = "\x1b[33m";
10
- export const CYAN = "\x1b[36m";
11
- export const CLEAR_LINE = "\x1b[2K\r";
3
+ // TTY detection — disable colors/formatting when piped
4
+ const isTTY = process.stdout.isTTY ?? false;
5
+
6
+ // ANSI colors (empty strings when not a TTY)
7
+ export const DIM = isTTY ? "\x1b[2m" : "";
8
+ export const BOLD = isTTY ? "\x1b[1m" : "";
9
+ export const RESET = isTTY ? "\x1b[0m" : "";
10
+ export const RED = isTTY ? "\x1b[31m" : "";
11
+ export const GREEN = isTTY ? "\x1b[32m" : "";
12
+ export const YELLOW = isTTY ? "\x1b[33m" : "";
13
+ export const CYAN = isTTY ? "\x1b[36m" : "";
14
+ export const CLEAR_LINE = isTTY ? "\x1b[2K\r" : "";
12
15
 
13
16
  // Icons
14
17
  export const ICON_PASS = "\u2713";
@@ -19,11 +22,120 @@ export const ICON_RUNNING = "\u21bb";
19
22
  // Spinner frames
20
23
  export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
21
24
 
25
+ /** Print error to stderr and exit with code 1. */
22
26
  export function fail(msg: string): never {
23
- console.log(msg);
27
+ console.error(msg);
24
28
  process.exit(1);
25
29
  }
26
30
 
31
+ // ---------------------------------------------------------------------------
32
+ // Argument parser
33
+ // ---------------------------------------------------------------------------
34
+
35
+ export interface ParsedArgs {
36
+ /** Positional arguments (everything that isn't a flag or flag value). */
37
+ positional: string[];
38
+ /** Named flags: --foo bar → { foo: "bar" }, --flag → { flag: true }, --no-flag → { flag: false }. */
39
+ flags: Record<string, string | boolean>;
40
+ /** Returns a flag value as string, or undefined. */
41
+ getString(name: string): string | undefined;
42
+ /** Returns a flag as boolean (true if --name, false if --no-name, undefined if absent). */
43
+ getBool(name: string): boolean | undefined;
44
+ /** Returns true if --help or -h is present. */
45
+ help: boolean;
46
+ }
47
+
48
+ /**
49
+ * Parse CLI arguments into positional args and named flags.
50
+ *
51
+ * Supports:
52
+ * - `--flag value` → { flag: "value" }
53
+ * - `--flag` (no value or next arg is a flag) → { flag: true }
54
+ * - `--no-flag` → { flag: false }
55
+ * - `-h` / `--help` → help: true
56
+ * - Positional args (anything not a flag or flag value)
57
+ *
58
+ * @param argv - Arguments to parse (default: process.argv.slice(3) for subcommands)
59
+ * @param boolFlags - Flag names that are always boolean (never consume the next arg as value)
60
+ */
61
+ export function parseArgs(argv?: string[], boolFlags: string[] = []): ParsedArgs {
62
+ const args = argv ?? process.argv.slice(3);
63
+ const positional: string[] = [];
64
+ const flags: Record<string, string | boolean> = {};
65
+ const boolSet = new Set(boolFlags);
66
+
67
+ let i = 0;
68
+ while (i < args.length) {
69
+ const arg = args[i];
70
+
71
+ if (arg === "--") {
72
+ // Everything after -- is positional
73
+ positional.push(...args.slice(i + 1));
74
+ break;
75
+ }
76
+
77
+ if (arg === "-h" || arg === "--help") {
78
+ flags.help = true;
79
+ i++;
80
+ continue;
81
+ }
82
+
83
+ if (arg.startsWith("--no-")) {
84
+ const name = arg.slice(5);
85
+ flags[name] = false;
86
+ i++;
87
+ continue;
88
+ }
89
+
90
+ if (arg.startsWith("--")) {
91
+ const name = arg.slice(2);
92
+ const next = args[i + 1];
93
+
94
+ if (boolSet.has(name) || !next || next.startsWith("-")) {
95
+ flags[name] = true;
96
+ i++;
97
+ } else {
98
+ flags[name] = next;
99
+ i += 2;
100
+ }
101
+ continue;
102
+ }
103
+
104
+ if (arg.startsWith("-") && arg.length === 2) {
105
+ // Short flag like -c value
106
+ const name = arg.slice(1);
107
+ const next = args[i + 1];
108
+ if (!next || next.startsWith("-")) {
109
+ flags[name] = true;
110
+ i++;
111
+ } else {
112
+ flags[name] = next;
113
+ i += 2;
114
+ }
115
+ continue;
116
+ }
117
+
118
+ positional.push(arg);
119
+ i++;
120
+ }
121
+
122
+ return {
123
+ positional,
124
+ flags,
125
+ getString(name: string): string | undefined {
126
+ const v = flags[name];
127
+ return typeof v === "string" ? v : undefined;
128
+ },
129
+ getBool(name: string): boolean | undefined {
130
+ const v = flags[name];
131
+ return typeof v === "boolean" ? v : undefined;
132
+ },
133
+ get help() {
134
+ return flags.help === true;
135
+ },
136
+ };
137
+ }
138
+
27
139
  export function pickFromList(
28
140
  rl: readline.Interface,
29
141
  items: { name: string; label: string }[],