@chenpengfei/daily-brief 0.1.2 → 0.1.3

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/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  All notable changes for Formal Releases are recorded here. GitHub Release notes should be derived from the matching version entry.
4
4
 
5
+ ## 0.1.3 - 2026-06-06
6
+
7
+ Patch release for runtime configuration hardening and richer local status output.
8
+
9
+ ### User-visible Changes
10
+
11
+ - Expands `daily-brief status` into a local readiness report with setup checks, today's run state, active paths, system timezone, and the next suggested action.
12
+ - Keeps model and Discord secrets in `auth.json` referenced by `config.yaml`, removing runtime reliance on `env:NAME`, `.env`, and `DISCORD_WEBHOOK_URL` credential paths for installed usage.
13
+ - Reports Discord delivery as explicitly skipped when delivery is disabled or the stored webhook credential is missing.
14
+ - Keeps `daily-brief run-once --date YYYY-MM-DD` available for manual backfills while making `daily-brief status` a no-flag inspection command.
15
+
16
+ ### Installation and Upgrade Notes
17
+
18
+ - Upgrade with `npm install -g @chenpengfei/daily-brief@latest`.
19
+ - Run `daily-brief setup` after upgrading if your previous model or Discord configuration used environment-backed credential references.
20
+ - Scripted environments should write `config.yaml`, `sources.yaml`, and `auth.json` directly under `DAILY_BRIEF_HOME`; use `DAILY_BRIEF_DATA_HOME` when generated data should live elsewhere.
21
+
22
+ ### Known Limitations
23
+
24
+ - `daily-brief status` is read-only and local: it does not collect Sources, call an LLM, refresh credentials, generate a brief, or send Discord notifications.
25
+ - Environment variables remain limited to path overrides for installed usage: `DAILY_BRIEF_HOME` and `DAILY_BRIEF_DATA_HOME`.
26
+
5
27
  ## 0.1.2 - 2026-06-04
6
28
 
7
29
  Patch release for the simplified public CLI setup workflow.
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { getOAuthApiKey, loginOpenAICodex } from "@earendil-works/pi-ai/oauth";
3
- import { envNameFromCredentialRef, getCredential, isEnvCredentialRef, putCredential, readCredentialStore, readModelConfig, removeCredential, writeCredentialStore } from "../config/index.js";
3
+ import { getCredential, putCredential, readCredentialStore, readModelConfig, removeCredential, writeCredentialStore } from "../config/index.js";
4
4
  import { resolveDailyBriefPaths } from "../config/paths.js";
5
5
  export const defaultOAuthHelpers = {
6
6
  getOAuthApiKey,
@@ -10,7 +10,7 @@ export function readModelRuntimeConfig(env = process.env, options = {}) {
10
10
  const paths = resolveDailyBriefPaths(env);
11
11
  const configPath = options.configPath ?? paths.configPath;
12
12
  const authPath = options.authPath ?? paths.authPath;
13
- const config = readEnvModelConfig(env) ?? (existsSync(configPath) ? readModelConfig(configPath) : undefined);
13
+ const config = existsSync(configPath) ? readModelConfig(configPath) : undefined;
14
14
  if (!config) {
15
15
  return {
16
16
  provider: "openai-codex",
@@ -20,7 +20,7 @@ export function readModelRuntimeConfig(env = process.env, options = {}) {
20
20
  issues: [`Model config not found: ${configPath}. Run daily-brief setup or create config.yaml with model settings.`]
21
21
  };
22
22
  }
23
- const issues = credentialIssues(config.provider, config.credentialRef, env, authPath);
23
+ const issues = credentialIssues(config.provider, config.credentialRef, authPath);
24
24
  return {
25
25
  provider: config.provider,
26
26
  model: config.model,
@@ -38,10 +38,6 @@ export async function resolveModelApiKey(config, env = process.env, options = {}
38
38
  if (!credentialRef) {
39
39
  throw new Error(`credentialRef is required for ${config.provider}`);
40
40
  }
41
- if (isEnvCredentialRef(credentialRef)) {
42
- const envName = envNameFromCredentialRef(credentialRef);
43
- return env[envName]?.trim();
44
- }
45
41
  const authPath = options.authPath ?? resolveDailyBriefPaths(env).authPath;
46
42
  const credential = getCredential(credentialRef, authPath);
47
43
  if (!credential) {
@@ -108,17 +104,13 @@ export function statusModelCredentials(env = process.env, options = {}) {
108
104
  secret: "<redacted>"
109
105
  }));
110
106
  }
111
- function credentialIssues(provider, credentialRef, env, authPath) {
107
+ function credentialIssues(provider, credentialRef, authPath) {
112
108
  if (provider === "faux") {
113
109
  return [];
114
110
  }
115
111
  if (!credentialRef) {
116
112
  return [`credentialRef is required for ${provider}`];
117
113
  }
118
- if (isEnvCredentialRef(credentialRef)) {
119
- const envName = envNameFromCredentialRef(credentialRef);
120
- return env[envName]?.trim() ? [] : [`${envName} is required for credentialRef ${credentialRef}`];
121
- }
122
114
  const credential = getCredential(credentialRef, authPath);
123
115
  if (!credential) {
124
116
  return [`Credential not found: ${credentialRef}`];
@@ -128,65 +120,6 @@ function credentialIssues(provider, credentialRef, env, authPath) {
128
120
  }
129
121
  return [];
130
122
  }
131
- function readEnvModelConfig(env) {
132
- const provider = env.DAILY_BRIEF_MODEL_PROVIDER?.trim();
133
- if (!provider) {
134
- return undefined;
135
- }
136
- const normalizedProvider = normalizeProvider(provider, env);
137
- return {
138
- provider: normalizedProvider,
139
- model: env.DAILY_BRIEF_MODEL?.trim() || defaultModelForProvider(normalizedProvider),
140
- ...(env.DAILY_BRIEF_MODEL_BASE_URL?.trim() ? { baseUrl: env.DAILY_BRIEF_MODEL_BASE_URL.trim() } : {}),
141
- ...readEnvCredentialRef(env, normalizedProvider)
142
- };
143
- }
144
- function normalizeProvider(value, env) {
145
- const provider = value.trim().toLowerCase();
146
- if (provider === "codex" || provider === "hermes") {
147
- return "openai-codex";
148
- }
149
- if (provider === "faux") {
150
- if (env.DAILY_BRIEF_ALLOW_FAUX_PROVIDER === "true") {
151
- return provider;
152
- }
153
- throw new Error("DAILY_BRIEF_MODEL_PROVIDER=faux is only allowed when DAILY_BRIEF_ALLOW_FAUX_PROVIDER=true");
154
- }
155
- if (provider === "openai-codex" || provider === "openai" || provider === "deepseek" || provider === "openai-compatible") {
156
- return provider;
157
- }
158
- throw new Error(`Unsupported DAILY_BRIEF_MODEL_PROVIDER: ${value}`);
159
- }
160
- function defaultModelForProvider(provider) {
161
- if (provider === "openai-codex") {
162
- return "gpt-5.5";
163
- }
164
- if (provider === "openai") {
165
- return "gpt-4.1-mini";
166
- }
167
- if (provider === "deepseek") {
168
- return "deepseek-chat";
169
- }
170
- if (provider === "openai-compatible") {
171
- return "openai-compatible-model";
172
- }
173
- return "faux-daily-brief-renderer";
174
- }
175
- function defaultCredentialRefForProvider(provider) {
176
- if (provider === "faux") {
177
- return undefined;
178
- }
179
- if (provider === "openai") {
180
- return "env:OPENAI_API_KEY";
181
- }
182
- if (provider === "deepseek") {
183
- return "env:DEEPSEEK_API_KEY";
184
- }
185
- if (provider === "openai-compatible") {
186
- return "env:OPENAI_API_KEY";
187
- }
188
- return "openai-codex.default";
189
- }
190
123
  function persistRefreshedOAuthCredential(ref, credential, credentials, authPath) {
191
124
  const store = readCredentialStore(authPath);
192
125
  const previous = store.credentials[ref];
@@ -198,11 +131,6 @@ function persistRefreshedOAuthCredential(ref, credential, credentials, authPath)
198
131
  };
199
132
  writeCredentialStore(store, authPath);
200
133
  }
201
- function readEnvCredentialRef(env, provider) {
202
- const credentialRef = env.DAILY_BRIEF_MODEL_CREDENTIAL_REF?.trim() ||
203
- defaultCredentialRefForProvider(provider);
204
- return credentialRef ? { credentialRef } : {};
205
- }
206
134
  function promptForOAuth(io, message) {
207
135
  if (!io.prompt) {
208
136
  throw new Error(`OAuth login requires interactive input: ${message}`);
package/dist/src/cli.js CHANGED
@@ -6,8 +6,7 @@ import { createInterface } from "node:readline/promises";
6
6
  import { fileURLToPath, pathToFileURL } from "node:url";
7
7
  import { stringify } from "yaml";
8
8
  import { loginModelCredential, readModelRuntimeConfig, runOnce, toApiKeyCredential } from "./agent/index.js";
9
- import { resolveConfiguredWebhookUrl } from "./discord/index.js";
10
- import { defaultModelConfig, dateFromDateKey, formatDateKey, formatSourceRegistry, isEnvCredentialRef, loadSourceRegistry, putCredential, readDeliveryConfig, readDailyBriefConfig, readConfiguredTimezone, getCredential, resolveDailyBriefPaths, setSourceEnabled, validateSourceRegistry, writeDeliveryConfig, writeCredentialStore, writeModelConfig } from "./config/index.js";
9
+ import { defaultModelConfig, dateFromDateKey, formatDateKey, formatSourceRegistry, loadSourceRegistry, putCredential, readDeliveryConfig, readDailyBriefConfig, getCredential, resolveDailyBriefPaths, setSourceEnabled, validateSourceRegistry, writeDeliveryConfig, writeCredentialStore, writeModelConfig } from "./config/index.js";
11
10
  import { getOperationalStatus } from "./workflow/index.js";
12
11
  const consoleIo = {
13
12
  interactive: Boolean(processStdin.isTTY && processStdout.isTTY),
@@ -32,11 +31,6 @@ const consoleIo = {
32
31
  };
33
32
  export async function runCli(args, io = consoleIo, env = process.env) {
34
33
  const [command, subcommand, value] = args;
35
- const workflowFlags = parseFlags(args.slice(1));
36
- const options = {
37
- ...optionsFromEnv(env),
38
- ...readWorkflowDateOption(workflowFlags, env)
39
- };
40
34
  if (!command || command === "help" || command === "--help" || command === "-h") {
41
35
  printHelp(io);
42
36
  return;
@@ -46,6 +40,11 @@ export async function runCli(args, io = consoleIo, env = process.env) {
46
40
  return;
47
41
  }
48
42
  if (command === "run-once") {
43
+ const workflowFlags = parseFlags(args.slice(1));
44
+ const options = {
45
+ ...optionsFromEnv(env),
46
+ ...readWorkflowDateOption(workflowFlags)
47
+ };
49
48
  await assertWorkflowConfigured(options.sourceRegistryPath);
50
49
  io.stdout("Daily Brief run started");
51
50
  io.stdout(`Date: ${options.dateKey}`);
@@ -74,14 +73,16 @@ export async function runCli(args, io = consoleIo, env = process.env) {
74
73
  return;
75
74
  }
76
75
  if (command === "status") {
77
- const status = await getOperationalStatus(options);
78
- io.stdout(`${status.health}: ${status.message}`);
79
- for (const failure of status.materialPartialFailures) {
80
- io.stdout(`- ${failure}`);
76
+ if (args.length > 1) {
77
+ throw new Error("daily-brief status does not accept flags.");
81
78
  }
79
+ const options = optionsFromEnv(env);
80
+ const status = await getOperationalStatus(options);
81
+ printOperationalStatus(status, io);
82
82
  return;
83
83
  }
84
84
  if (command === "sources") {
85
+ const options = optionsFromEnv(env);
85
86
  await handleSourcesCommand(subcommand, value, io, options.sourceRegistryPath);
86
87
  return;
87
88
  }
@@ -116,7 +117,52 @@ function countAgentStageEvents(events) {
116
117
  function formatDeliveryStatus(delivery) {
117
118
  return `${delivery.status}${delivery.reason ? ` (${delivery.reason})` : ""}`;
118
119
  }
119
- function readWorkflowDateOption(flags, env) {
120
+ function printOperationalStatus(status, io) {
121
+ io.stdout("Daily Brief status");
122
+ io.stdout(`Health: ${status.health} - ${status.message}`);
123
+ io.stdout(`Date: ${status.dateKey}`);
124
+ io.stdout(`System timezone: ${status.systemTimezone}`);
125
+ io.stdout(`Home: ${status.paths.home}`);
126
+ io.stdout(`Data: ${status.paths.dataHome}`);
127
+ io.stdout("");
128
+ io.stdout("Setup readiness");
129
+ io.stdout(formatStatusCheck("Config", status.setup.config));
130
+ io.stdout(formatSourceRegistryCheck(status.setup.sourceRegistry));
131
+ io.stdout(formatModelCheck(status.setup.model));
132
+ io.stdout(formatStatusCheck("Delivery", status.setup.delivery));
133
+ io.stdout(formatStatusCheck("Data", status.setup.data));
134
+ io.stdout("");
135
+ io.stdout("Today run state");
136
+ io.stdout(formatStatusCheck("Source Items", status.today.sourceItems));
137
+ io.stdout(formatStatusCheck("Brief Archive", status.today.briefArchive));
138
+ io.stdout(formatStatusCheck("Agent Run Artifacts", status.today.agentRunArtifacts));
139
+ io.stdout("");
140
+ io.stdout(`Next: ${status.nextAction}`);
141
+ for (const failure of status.materialPartialFailures) {
142
+ io.stdout(`- ${failure}`);
143
+ }
144
+ }
145
+ function formatStatusCheck(label, check) {
146
+ const detail = check.detail ? ` - ${check.detail}` : "";
147
+ const path = check.path ? ` (${check.path})` : "";
148
+ return `- ${label}: ${check.state} - ${check.label}${detail}${path}`;
149
+ }
150
+ function formatSourceRegistryCheck(check) {
151
+ const counts = typeof check.enabledCount === "number" && typeof check.totalCount === "number"
152
+ ? ` - ${check.enabledCount}/${check.totalCount} enabled`
153
+ : "";
154
+ const detail = check.detail && !counts.includes(check.detail) ? ` - ${check.detail}` : "";
155
+ const path = check.path ? ` (${check.path})` : "";
156
+ return `- Source Registry: ${check.state} - ${check.label}${counts}${detail}${path}`;
157
+ }
158
+ function formatModelCheck(check) {
159
+ const selected = check.provider && check.model ? ` - ${check.provider}/${check.model}` : "";
160
+ const credential = check.credentialRef ? ` - credential ${check.credentialRef}` : "";
161
+ const detail = check.detail ? ` - ${check.detail}` : "";
162
+ const path = check.path ? ` (${check.path})` : "";
163
+ return `- Model: ${check.state} - ${check.label}${selected}${credential}${detail}${path}`;
164
+ }
165
+ function readWorkflowDateOption(flags) {
120
166
  if (flags.date) {
121
167
  if (!/^\d{4}-\d{2}-\d{2}$/.test(flags.date)) {
122
168
  throw new Error("--date must use YYYY-MM-DD");
@@ -124,8 +170,7 @@ function readWorkflowDateOption(flags, env) {
124
170
  return { date: dateFromDateKey(flags.date), dateKey: flags.date };
125
171
  }
126
172
  const now = new Date();
127
- const paths = resolveDailyBriefPaths(env);
128
- const timezone = readConfiguredTimezone(paths.configPath) ?? env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
173
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
129
174
  return { date: now, dateKey: formatDateKey(now, timezone) };
130
175
  }
131
176
  async function assertWorkflowConfigured(sourceRegistryPath) {
@@ -208,14 +253,12 @@ async function handleSetupCommand(args, io, env) {
208
253
  requireInteractiveSetup(io);
209
254
  const paths = resolveDailyBriefPaths(env);
210
255
  const existingConfig = readDailyBriefConfig(paths.configPath);
211
- const timezone = await promptWithDefault(io, "Timezone", readConfiguredTimezone(paths.configPath) ?? env.TZ?.trim() ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC");
212
256
  await mkdir(paths.home, { recursive: true });
213
257
  await mkdir(paths.sourceItemRoot, { recursive: true });
214
258
  await mkdir(paths.agentRunRoot, { recursive: true });
215
259
  await mkdir(paths.briefArchiveRoot, { recursive: true });
216
260
  await writeSetupBaseConfig(paths.configPath, {
217
261
  ...existingConfig,
218
- timezone,
219
262
  brief: normalizeBriefConfig(existingConfig.brief)
220
263
  });
221
264
  if (!(await exists(paths.authPath))) {
@@ -254,7 +297,6 @@ async function handleSetupCommand(args, io, env) {
254
297
  const delivery = readDeliveryConfig(paths.configPath);
255
298
  io.stdout(`Daily Brief home: ${paths.home}`);
256
299
  io.stdout(`Daily Brief data: ${paths.dataHome}`);
257
- io.stdout(`Timezone: ${timezone}`);
258
300
  io.stdout(`Source Registry: ${paths.sourceRegistryPath}`);
259
301
  io.stdout(`Model: ${readiness.provider}/${readiness.model}`);
260
302
  io.stdout(`Model credential: ${readiness.ready ? "configured" : "missing"}`);
@@ -292,7 +334,7 @@ async function configureModelThroughSetup(io, env) {
292
334
  const current = readDailyBriefConfig(paths.configPath).model ?? defaultModelConfig();
293
335
  const provider = readProviderFlag(await promptWithDefault(io, "LLM Provider (openai-codex/openai/deepseek/openai-compatible)", current.provider));
294
336
  const model = await promptWithDefault(io, "Model", current.provider === provider ? current.model : defaultModelForProvider(provider));
295
- io.stdout("Credential name identifies where Daily Brief finds the model secret. Use the default unless you manage multiple credentials; env:NAME reads an environment variable.");
337
+ io.stdout("Credential name identifies where Daily Brief finds the model secret in auth.json. Use the default unless you manage multiple credentials.");
296
338
  const credentialRef = await promptWithDefault(io, "Model credential name", current.provider === provider ? current.credentialRef ?? defaultCredentialRef(provider) ?? "" : defaultCredentialRef(provider) ?? "");
297
339
  const baseUrl = provider === "openai-compatible"
298
340
  ? await promptWithDefault(io, "Base URL", current.provider === provider ? current.baseUrl ?? "" : "")
@@ -335,25 +377,6 @@ async function maybeConfigureModelCredential(input) {
335
377
  }
336
378
  return;
337
379
  }
338
- if (isEnvCredentialRef(input.credentialRef)) {
339
- const hasEnvValue = Boolean(input.env[input.credentialRef.slice("env:".length)]?.trim());
340
- if (hasEnvValue) {
341
- input.io.stdout("Model credential: configured from environment");
342
- return;
343
- }
344
- if (!(await promptYesNo(input.io, "Store API key in credential store instead of using the environment?", false))) {
345
- input.io.stdout("Model credential: missing until the environment variable is set");
346
- return;
347
- }
348
- const storedRef = await promptWithDefault(input.io, "Stored credential name", `${input.provider}.default`);
349
- const apiKey = await promptWithDefault(input.io, "API key input may be visible in this terminal. API key", "");
350
- if (apiKey) {
351
- putCredential(storedRef, toApiKeyCredential(input.provider, apiKey), paths.authPath);
352
- writeModelConfig({ ...readDailyBriefConfig(paths.configPath).model, credentialRef: storedRef }, paths.configPath);
353
- input.io.stdout(`Stored credential: ${storedRef}`);
354
- }
355
- return;
356
- }
357
380
  if (getCredential(input.credentialRef, paths.authPath)) {
358
381
  input.io.stdout("Model credential: configured");
359
382
  return;
@@ -499,13 +522,13 @@ function defaultCredentialRef(provider) {
499
522
  return undefined;
500
523
  }
501
524
  if (provider === "openai") {
502
- return "env:OPENAI_API_KEY";
525
+ return "openai.default";
503
526
  }
504
527
  if (provider === "deepseek") {
505
- return "env:DEEPSEEK_API_KEY";
528
+ return "deepseek.default";
506
529
  }
507
530
  if (provider === "openai-compatible") {
508
- return "env:OPENAI_API_KEY";
531
+ return "openai-compatible.default";
509
532
  }
510
533
  return defaultModelConfig().credentialRef;
511
534
  }
@@ -518,63 +541,19 @@ async function promptWithDefault(io, label, defaultValue) {
518
541
  }
519
542
  function optionsFromEnv(env) {
520
543
  const paths = resolveDailyBriefPaths(env);
521
- const discordWebhookUrl = resolveConfiguredWebhookUrl(env);
522
544
  return {
545
+ env,
546
+ dataHome: paths.dataHome,
547
+ configPath: paths.configPath,
548
+ authPath: paths.authPath,
523
549
  sourceRegistryPath: paths.sourceRegistryPath,
524
550
  sourceItemRoot: paths.sourceItemRoot,
525
551
  agentRunRoot: paths.agentRunRoot,
526
552
  archiveRoot: paths.briefArchiveRoot,
527
553
  modelRuntimeEnv: env,
528
- discordEnv: env,
529
- ...(discordWebhookUrl ? { discordWebhookUrl } : {}),
530
- ...(env.DAILY_BRIEF_DISCORD_TEMPLATE_PATH ? { discordTemplatePath: env.DAILY_BRIEF_DISCORD_TEMPLATE_PATH } : {})
554
+ discordEnv: env
531
555
  };
532
556
  }
533
- export async function loadDotenvFile(path = ".env", env = process.env) {
534
- let contents;
535
- try {
536
- contents = await readFile(path, "utf8");
537
- }
538
- catch (error) {
539
- if (isNodeError(error) && error.code === "ENOENT") {
540
- return;
541
- }
542
- throw error;
543
- }
544
- for (const line of contents.split("\n")) {
545
- const entry = parseDotenvLine(line);
546
- if (!entry || env[entry.key] !== undefined) {
547
- continue;
548
- }
549
- env[entry.key] = entry.value;
550
- }
551
- }
552
- function parseDotenvLine(line) {
553
- const trimmed = line.trim();
554
- if (trimmed.length === 0 || trimmed.startsWith("#")) {
555
- return undefined;
556
- }
557
- const equalsIndex = trimmed.indexOf("=");
558
- if (equalsIndex <= 0) {
559
- return undefined;
560
- }
561
- const key = trimmed.slice(0, equalsIndex).trim();
562
- const rawValue = trimmed.slice(equalsIndex + 1).trim();
563
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
564
- return undefined;
565
- }
566
- return { key, value: unquoteDotenvValue(rawValue) };
567
- }
568
- function unquoteDotenvValue(value) {
569
- if (value.length >= 2) {
570
- const quote = value[0];
571
- const last = value[value.length - 1];
572
- if ((quote === "\"" || quote === "'") && last === quote) {
573
- return value.slice(1, -1);
574
- }
575
- }
576
- return value;
577
- }
578
557
  function isNodeError(error) {
579
558
  return error instanceof Error && "code" in error;
580
559
  }
@@ -601,7 +580,6 @@ isCliEntrypoint(process.argv[1])
601
580
  if (!isEntrypoint) {
602
581
  return false;
603
582
  }
604
- await loadDotenvFile();
605
583
  await runCli(process.argv.slice(2));
606
584
  return true;
607
585
  })
@@ -38,9 +38,6 @@ export function removeCredential(ref, path = resolveDailyBriefPaths().authPath)
38
38
  return store;
39
39
  }
40
40
  export function getCredential(ref, path = resolveDailyBriefPaths().authPath) {
41
- if (isEnvCredentialRef(ref)) {
42
- return undefined;
43
- }
44
41
  assertStoredCredentialRef(ref);
45
42
  return readCredentialStore(path).credentials[ref];
46
43
  }
@@ -54,18 +51,9 @@ export function redactCredentialStore(store) {
54
51
  }
55
52
  ]));
56
53
  }
57
- export function isEnvCredentialRef(ref) {
58
- return ref.startsWith("env:") && ref.slice("env:".length).trim().length > 0;
59
- }
60
- export function envNameFromCredentialRef(ref) {
61
- if (!isEnvCredentialRef(ref)) {
62
- throw new Error(`Credential reference is not an env ref: ${ref}`);
63
- }
64
- return ref.slice("env:".length).trim();
65
- }
66
54
  export function assertStoredCredentialRef(ref) {
67
- if (isEnvCredentialRef(ref)) {
68
- throw new Error(`Credential reference ${ref} is environment-backed and cannot be stored in auth.json`);
55
+ if (ref.startsWith("env:")) {
56
+ throw new Error(`Credential reference ${ref} is not supported; use a stored credential name in auth.json`);
69
57
  }
70
58
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(ref)) {
71
59
  throw new Error(`Invalid credentialRef: ${ref}`);
@@ -16,10 +16,6 @@ export function readDailyBriefConfig(path = resolveDailyBriefPaths().configPath)
16
16
  export function readModelConfig(path = resolveDailyBriefPaths().configPath) {
17
17
  return readDailyBriefConfig(path).model;
18
18
  }
19
- export function readConfiguredTimezone(path = resolveDailyBriefPaths().configPath) {
20
- const timezone = readDailyBriefConfig(path).timezone;
21
- return typeof timezone === "string" && timezone.trim().length > 0 ? timezone.trim() : undefined;
22
- }
23
19
  export function writeModelConfig(config, path = resolveDailyBriefPaths().configPath) {
24
20
  const current = readDailyBriefConfig(path);
25
21
  const next = {
@@ -72,6 +68,9 @@ export function parseModelConfig(value) {
72
68
  if ("apiKey" in value || "secret" in value || "accessToken" in value || "refreshToken" in value) {
73
69
  throw new Error("config.model must not contain secrets; use auth.json via credentialRef");
74
70
  }
71
+ if (credentialRef?.startsWith("env:")) {
72
+ throw new Error("config.model.credentialRef must be a stored credential name, not env:NAME");
73
+ }
75
74
  if (provider === "openai-compatible" && !baseUrl) {
76
75
  throw new Error("config.model.baseUrl is required when provider is openai-compatible");
77
76
  }
@@ -94,10 +93,11 @@ function readProvider(value) {
94
93
  if (provider === "codex" || provider === "hermes") {
95
94
  return "openai-codex";
96
95
  }
97
- if (provider === "faux") {
98
- throw new Error("config.model.provider must not be faux; faux is only available through test-only runtime injection");
99
- }
100
- if (provider === "openai-codex" || provider === "openai" || provider === "deepseek" || provider === "openai-compatible") {
96
+ if (provider === "faux" ||
97
+ provider === "openai-codex" ||
98
+ provider === "openai" ||
99
+ provider === "deepseek" ||
100
+ provider === "openai-compatible") {
101
101
  return provider;
102
102
  }
103
103
  throw new Error(`Unsupported model provider: ${provider}`);
@@ -22,7 +22,7 @@ export async function deliverCoreFailureNotification(failure, options = {}) {
22
22
  async function sendDiscordContent(content, options) {
23
23
  const webhookUrl = options.webhookUrl ?? resolveConfiguredWebhookUrl(options.env ?? process.env);
24
24
  if (!webhookUrl) {
25
- return { status: "skipped", reason: "DISCORD_WEBHOOK_URL is not configured" };
25
+ return { status: "skipped", reason: "Discord delivery webhook is not configured" };
26
26
  }
27
27
  try {
28
28
  const fetchImpl = options.fetchImpl ?? fetch;
@@ -67,9 +67,6 @@ export function resolveConfiguredWebhookUrl(env = process.env) {
67
67
  if (config?.enabled === false) {
68
68
  return undefined;
69
69
  }
70
- if (env.DISCORD_WEBHOOK_URL) {
71
- return env.DISCORD_WEBHOOK_URL;
72
- }
73
70
  if (!config?.enabled || !config.webhookRef) {
74
71
  return undefined;
75
72
  }
@@ -1,6 +1,9 @@
1
- import { readFile } from "node:fs/promises";
2
- import { join } from "node:path";
3
- import { loadSourceRegistry, resolveDailyBriefPaths } from "../config/index.js";
1
+ import { constants } from "node:fs";
2
+ import { access, readdir } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { readModelRuntimeConfig } from "../agent/model-runtime-config.js";
5
+ import { formatDateKey, getCredential, loadSourceRegistry, readDeliveryConfig, resolveDailyBriefPaths } from "../config/index.js";
6
+ import { agentRunArtifactPath, readSourceItems, sourceItemStorePath } from "../storage/index.js";
4
7
  export function evaluateWorkflowStatus(input) {
5
8
  if (input.coreFailure) {
6
9
  return {
@@ -50,46 +53,282 @@ export function createCoreWorkflowFailureNotification(failure) {
50
53
  }
51
54
  export async function getOperationalStatus(options = {}) {
52
55
  const date = options.date ?? new Date();
56
+ const systemTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
57
+ const dateKey = options.dateKey ?? formatDateKey(date, systemTimezone);
58
+ const resolvedPaths = resolveDailyBriefPaths(options.env);
59
+ const paths = {
60
+ ...resolvedPaths,
61
+ ...(options.dataHome ? { dataHome: options.dataHome } : {}),
62
+ ...(options.configPath ? { configPath: options.configPath } : {}),
63
+ ...(options.authPath ? { authPath: options.authPath } : {}),
64
+ ...(options.sourceRegistryPath ? { sourceRegistryPath: options.sourceRegistryPath } : {}),
65
+ ...(options.sourceItemRoot ? { sourceItemRoot: options.sourceItemRoot } : {}),
66
+ ...(options.agentRunRoot ? { agentRunRoot: options.agentRunRoot } : {}),
67
+ ...(options.archiveRoot ? { briefArchiveRoot: options.archiveRoot } : {})
68
+ };
69
+ const archivePath = briefArchivePath(date, paths.briefArchiveRoot, dateKey);
70
+ const sourceItemPath = sourceItemStorePath(date, paths.sourceItemRoot, dateKey);
71
+ const agentRunDirectory = dirname(agentRunArtifactPath(date, "status-probe", paths.agentRunRoot, dateKey));
72
+ const setup = {
73
+ config: await inspectConfig(paths.configPath),
74
+ sourceRegistry: await inspectSourceRegistry(paths.sourceRegistryPath),
75
+ model: inspectModel(paths.configPath, paths.authPath, options.env),
76
+ delivery: inspectDelivery(paths.configPath, paths.authPath),
77
+ data: await inspectDataDirectory(paths.dataHome)
78
+ };
79
+ const today = {
80
+ sourceItems: await inspectSourceItems(date, paths.sourceItemRoot, dateKey, sourceItemPath),
81
+ briefArchive: await inspectBriefArchive(archivePath),
82
+ agentRunArtifacts: await inspectAgentRunArtifacts(agentRunDirectory)
83
+ };
84
+ const nextAction = chooseNextAction(setup, today);
85
+ const workflow = summarizeOperationalStatus(dateKey, setup, today);
86
+ return {
87
+ ...workflow,
88
+ dateKey,
89
+ systemTimezone,
90
+ paths: {
91
+ home: paths.home,
92
+ dataHome: paths.dataHome,
93
+ configPath: paths.configPath,
94
+ sourceRegistryPath: paths.sourceRegistryPath,
95
+ authPath: paths.authPath,
96
+ sourceItemRoot: paths.sourceItemRoot,
97
+ agentRunRoot: paths.agentRunRoot,
98
+ archiveRoot: paths.briefArchiveRoot
99
+ },
100
+ setup,
101
+ today,
102
+ nextAction
103
+ };
104
+ }
105
+ function isMaterialPartialFailure(message) {
106
+ const normalized = message.toLowerCase();
107
+ const nonMaterialPatterns = ["missing transcript", "transcript missing", "rate limit", "parse failure"];
108
+ return !nonMaterialPatterns.some((pattern) => normalized.includes(pattern));
109
+ }
110
+ function briefArchivePath(date, root, dateKey) {
111
+ const datePart = dateKey ?? date.toISOString().slice(0, 10);
112
+ const [year, month] = datePart.split("-");
113
+ if (!year || !month) {
114
+ throw new Error(`Invalid archive date: ${datePart}`);
115
+ }
116
+ return join(root, year, month, `${datePart}.md`);
117
+ }
118
+ async function inspectConfig(path) {
119
+ return (await canRead(path))
120
+ ? { state: "ok", label: "Config file found", path }
121
+ : { state: "missing", label: "Config file missing", path };
122
+ }
123
+ async function inspectSourceRegistry(path) {
124
+ try {
125
+ const registry = await loadSourceRegistry(path);
126
+ const enabledCount = registry.sources.filter((source) => source.enabled).length;
127
+ return {
128
+ state: "ok",
129
+ label: "Source Registry valid",
130
+ path,
131
+ enabledCount,
132
+ totalCount: registry.sources.length,
133
+ detail: `${enabledCount}/${registry.sources.length} enabled`
134
+ };
135
+ }
136
+ catch (error) {
137
+ return {
138
+ state: (await canRead(path)) ? "invalid" : "missing",
139
+ label: (await canRead(path)) ? "Source Registry invalid" : "Source Registry missing",
140
+ path,
141
+ detail: error instanceof Error ? error.message : String(error)
142
+ };
143
+ }
144
+ }
145
+ function inspectModel(configPath, authPath, env) {
146
+ try {
147
+ const model = readModelRuntimeConfig(env, { configPath, authPath });
148
+ return {
149
+ state: model.ready ? "ok" : "not-ready",
150
+ label: model.ready ? "Model ready" : "Model not ready",
151
+ path: configPath,
152
+ provider: model.provider,
153
+ model: model.model,
154
+ ...(model.credentialRef ? { credentialRef: model.credentialRef } : {}),
155
+ ...(model.issues.length > 0 ? { detail: model.issues.join("; ") } : {})
156
+ };
157
+ }
158
+ catch (error) {
159
+ return {
160
+ state: "invalid",
161
+ label: "Model config invalid",
162
+ path: configPath,
163
+ detail: error instanceof Error ? error.message : String(error)
164
+ };
165
+ }
166
+ }
167
+ function inspectDelivery(configPath, authPath) {
168
+ try {
169
+ const delivery = readDeliveryConfig(configPath);
170
+ if (!delivery?.enabled) {
171
+ return { state: "disabled", label: "Discord delivery disabled", path: configPath };
172
+ }
173
+ if (!delivery.webhookRef) {
174
+ return { state: "not-ready", label: "Discord delivery missing webhook credential name", path: configPath };
175
+ }
176
+ const credential = getCredential(delivery.webhookRef, authPath);
177
+ if (!credential) {
178
+ return {
179
+ state: "not-ready",
180
+ label: "Discord delivery webhook credential missing",
181
+ path: authPath,
182
+ detail: delivery.webhookRef
183
+ };
184
+ }
185
+ if (credential.type !== "webhook" || credential.provider !== "discord") {
186
+ return {
187
+ state: "invalid",
188
+ label: "Discord delivery credential is not a webhook",
189
+ path: authPath,
190
+ detail: delivery.webhookRef
191
+ };
192
+ }
193
+ return { state: "ok", label: "Discord delivery ready", path: authPath, detail: delivery.webhookRef };
194
+ }
195
+ catch (error) {
196
+ return {
197
+ state: "invalid",
198
+ label: "Discord delivery config invalid",
199
+ path: configPath,
200
+ detail: error instanceof Error ? error.message : String(error)
201
+ };
202
+ }
203
+ }
204
+ async function inspectDataDirectory(path) {
205
+ try {
206
+ await access(path, constants.W_OK);
207
+ return { state: "ok", label: "Data directory writable", path };
208
+ }
209
+ catch (error) {
210
+ return {
211
+ state: (await canRead(path)) ? "not-ready" : "missing",
212
+ label: (await canRead(path)) ? "Data directory is not writable" : "Data directory missing",
213
+ path,
214
+ detail: error instanceof Error ? error.message : String(error)
215
+ };
216
+ }
217
+ }
218
+ async function inspectSourceItems(date, root, dateKey, path) {
53
219
  try {
54
- await loadSourceRegistry(options.sourceRegistryPath);
220
+ const items = await readSourceItems(date, root, dateKey);
221
+ return items.length > 0
222
+ ? { state: "ok", label: "Source Items found", path, itemCount: items.length, detail: `${items.length} item(s)` }
223
+ : { state: "missing", label: "No Source Items for today", path, itemCount: 0 };
55
224
  }
56
225
  catch (error) {
226
+ return {
227
+ state: "invalid",
228
+ label: "Source Items invalid",
229
+ path,
230
+ detail: error instanceof Error ? error.message : String(error)
231
+ };
232
+ }
233
+ }
234
+ async function inspectBriefArchive(path) {
235
+ return (await canRead(path))
236
+ ? { state: "ok", label: "Daily Brief archive found", path }
237
+ : { state: "missing", label: "No Daily Brief archive for today", path };
238
+ }
239
+ async function inspectAgentRunArtifacts(directory) {
240
+ try {
241
+ const files = (await readdir(directory)).filter((file) => file.endsWith(".json"));
242
+ return files.length > 0
243
+ ? {
244
+ state: "ok",
245
+ label: "Agent Run Artifacts found",
246
+ path: join(directory, files[files.length - 1] ?? ""),
247
+ fileCount: files.length,
248
+ detail: `${files.length} artifact(s)`
249
+ }
250
+ : { state: "missing", label: "No Agent Run Artifacts for today", path: directory, fileCount: 0 };
251
+ }
252
+ catch (error) {
253
+ if (isNodeError(error) && error.code === "ENOENT") {
254
+ return { state: "missing", label: "No Agent Run Artifacts for today", path: directory, fileCount: 0 };
255
+ }
256
+ return {
257
+ state: "invalid",
258
+ label: "Agent Run Artifacts unreadable",
259
+ path: directory,
260
+ detail: error instanceof Error ? error.message : String(error)
261
+ };
262
+ }
263
+ }
264
+ function summarizeOperationalStatus(dateKey, setup, today) {
265
+ if (setup.sourceRegistry.state === "missing" || setup.sourceRegistry.state === "invalid") {
57
266
  return evaluateWorkflowStatus({
58
267
  collectionResults: [],
59
268
  briefGenerated: false,
60
269
  coreFailure: {
61
270
  kind: "unreadable-source-registry",
62
- message: error instanceof Error ? error.message : String(error)
271
+ message: setup.sourceRegistry.detail ?? `${setup.sourceRegistry.label}: ${setup.sourceRegistry.path}`
63
272
  }
64
273
  });
65
274
  }
66
- const archivePath = briefArchivePath(date, options.archiveRoot ?? resolveDailyBriefPaths().briefArchiveRoot, options.dateKey);
67
- try {
68
- await readFile(archivePath, "utf8");
275
+ const setupReady = setup.config.state === "ok" &&
276
+ setup.model.state === "ok" &&
277
+ setup.data.state === "ok" &&
278
+ (setup.delivery.state === "ok" || setup.delivery.state === "disabled");
279
+ if (!setupReady) {
280
+ return {
281
+ health: "partial-failure",
282
+ message: "Daily Brief setup is incomplete.",
283
+ materialPartialFailures: []
284
+ };
69
285
  }
70
- catch {
286
+ if (today.briefArchive.state !== "ok") {
71
287
  return {
72
288
  health: "partial-failure",
73
- message: `No Daily Brief archived for ${options.dateKey ?? date.toISOString().slice(0, 10)} yet.`,
289
+ message: `No Daily Brief archived for ${dateKey} yet.`,
74
290
  materialPartialFailures: []
75
291
  };
76
292
  }
77
293
  return {
78
294
  health: "success",
79
- message: `Daily Brief archive exists for ${options.dateKey ?? date.toISOString().slice(0, 10)}.`,
295
+ message: `Daily Brief archive exists for ${dateKey}.`,
80
296
  materialPartialFailures: []
81
297
  };
82
298
  }
83
- function isMaterialPartialFailure(message) {
84
- const normalized = message.toLowerCase();
85
- const nonMaterialPatterns = ["missing transcript", "transcript missing", "rate limit", "parse failure"];
86
- return !nonMaterialPatterns.some((pattern) => normalized.includes(pattern));
299
+ function chooseNextAction(setup, today) {
300
+ if (setup.config.state !== "ok") {
301
+ return "daily-brief setup";
302
+ }
303
+ if (setup.sourceRegistry.state !== "ok") {
304
+ return setup.sourceRegistry.state === "missing" ? "daily-brief setup" : "daily-brief sources validate";
305
+ }
306
+ if (setup.sourceRegistry.enabledCount === 0) {
307
+ return "daily-brief sources list, then daily-brief sources enable <source-id>";
308
+ }
309
+ if (setup.model.state !== "ok") {
310
+ return "daily-brief setup";
311
+ }
312
+ if (setup.data.state !== "ok") {
313
+ return `Check data directory permissions: ${setup.data.path}`;
314
+ }
315
+ if (setup.delivery.state === "not-ready" || setup.delivery.state === "invalid") {
316
+ return "daily-brief setup";
317
+ }
318
+ if (today.briefArchive.state !== "ok") {
319
+ return "daily-brief run-once";
320
+ }
321
+ return "No action needed";
87
322
  }
88
- function briefArchivePath(date, root, dateKey) {
89
- const datePart = dateKey ?? date.toISOString().slice(0, 10);
90
- const [year, month] = datePart.split("-");
91
- if (!year || !month) {
92
- throw new Error(`Invalid archive date: ${datePart}`);
323
+ async function canRead(path) {
324
+ try {
325
+ await access(path, constants.R_OK);
326
+ return true;
93
327
  }
94
- return join(root, year, month, `${datePart}.md`);
328
+ catch {
329
+ return false;
330
+ }
331
+ }
332
+ function isNodeError(error) {
333
+ return error instanceof Error && "code" in error;
95
334
  }
@@ -36,20 +36,16 @@ npm run cli -- run-once
36
36
  npm run cli -- status
37
37
  ```
38
38
 
39
- `sources list` confirms which Sources are enabled, including the default `github-trending-daily` Source. `run-once` performs collection, brief generation, archive writing, and Discord Delivery once. `status` reports operational health after the run.
39
+ `sources list` confirms which Sources are enabled, including the default `github-trending-daily` Source. `run-once` performs collection, brief generation, archive writing, and Discord Delivery once. `status` reports setup readiness, today's run state, active paths, and the next suggested action.
40
40
 
41
- Discord Delivery uses the configured credential reference, or `DISCORD_WEBHOOK_URL` from the shell environment or local `.env`; `.env` is loaded automatically by the Operational CLI and does not need to be sourced manually. An explicit `delivery.enabled: false` in `config.yaml` disables delivery and takes precedence over `DISCORD_WEBHOOK_URL`.
41
+ Discord Delivery uses the configured credential reference in `config.yaml` and `auth.json`. If Discord Delivery is disabled or its webhook credential is missing, delivery is skipped with an explicit reason.
42
42
 
43
43
  ## Runtime configuration
44
44
 
45
- When the Operational CLI starts, it loads local `.env` values from the repository root if the file exists. Existing shell environment variables take precedence over `.env` values. The `.env` file is ignored by Git; use `.env.example` as the non-secret template.
46
-
47
45
  Installed operational paths can be adjusted through environment variables:
48
46
 
49
47
  - `DAILY_BRIEF_HOME`: user config directory. Defaults to `~/.daily-brief`.
50
48
  - `DAILY_BRIEF_DATA_HOME`: generated data directory. Defaults to `~/.daily-brief/data`.
51
- - `DAILY_BRIEF_DISCORD_TEMPLATE_PATH`: optional Discord notification template override. Defaults to the packaged Discord notification template.
52
- - `DISCORD_WEBHOOK_URL`: Discord webhook URL. If unset, Discord Delivery is skipped with an explicit reason.
53
49
 
54
50
  Model/provider and delivery configuration should normally be managed with:
55
51
 
@@ -57,7 +53,7 @@ Model/provider and delivery configuration should normally be managed with:
57
53
  daily-brief setup
58
54
  ```
59
55
 
60
- Secrets live in `~/.daily-brief/auth.json` or environment variables, never in `sources.yaml` or committed project files. `daily-brief setup` requires interactive input; CI and scripted environments should create the user configuration files directly under `DAILY_BRIEF_HOME`. Installed CLI configuration does not accept the faux provider; faux is reserved for tests through an explicit test-only runtime gate.
56
+ Secrets live in `~/.daily-brief/auth.json`, never in environment variables, `sources.yaml`, `config.yaml`, or committed project files. `daily-brief setup` requires interactive input; CI and scripted environments should create the user configuration files directly under `DAILY_BRIEF_HOME`. Faux provider coverage belongs in test configuration files, not runtime environment variables.
61
57
 
62
58
  `run-once` does not archive a normal Daily Brief when every enabled Source fails or when no Source Items exist for the requested date. It reports a Core Workflow Failure instead, because a false low-signal brief would hide collection failure.
63
59
 
@@ -61,7 +61,7 @@ Agent Stages require an LLM Provider Configuration before real Daily Brief gener
61
61
  daily-brief setup
62
62
  ```
63
63
 
64
- Use the setup wizard to choose the provider, model, credential name, and optional login/API key storage path. The credential name is a stable label, such as `openai.default` or `env:OPENAI_API_KEY`, that lets `config.yaml` find the secret in `auth.json` or in the shell environment. Do not put secrets in `sources.yaml` or committed project files.
64
+ Use the setup wizard to choose the provider, model, credential name, and optional login/API key storage path. The credential name is a stable label, such as `openai.default`, that lets `config.yaml` find the secret in `auth.json`. Do not put secrets in `sources.yaml`, `config.yaml`, or committed project files.
65
65
 
66
66
  ## Configure Delivery
67
67
 
@@ -102,7 +102,7 @@ Use status after setup, after manual runs, or when diagnosing failures:
102
102
  daily-brief status
103
103
  ```
104
104
 
105
- Status output is the operational surface for collection, analysis, archive, and delivery health.
105
+ Status output reports setup readiness, today's run state, active paths, system timezone, and the next suggested action. It is a local inspection command: it does not refresh OAuth, call an LLM, collect Sources, or send Discord notifications.
106
106
 
107
107
  ## Paths and Environment
108
108
 
@@ -110,10 +110,8 @@ The installed CLI uses user-home paths by default:
110
110
 
111
111
  - `DAILY_BRIEF_HOME`: configuration directory, default `~/.daily-brief`.
112
112
  - `DAILY_BRIEF_DATA_HOME`: generated data directory, default `~/.daily-brief/data`.
113
- - `DAILY_BRIEF_DISCORD_TEMPLATE_PATH`: optional Discord notification template override.
114
- - `DISCORD_WEBHOOK_URL`: optional Discord webhook URL. If `config.yaml` explicitly sets `delivery.enabled: false`, delivery stays disabled even when this environment variable is present.
115
113
 
116
- Repository checkouts may use a local `.env`; installed usage should normally rely on setup, user configuration files, and environment-backed credential references.
114
+ Model access, Discord delivery, Source choices, and secrets are configured through `config.yaml`, `sources.yaml`, and `auth.json`, not environment variables.
117
115
 
118
116
  ## Upgrade
119
117
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chenpengfei/daily-brief",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Agent-driven daily brief CLI for manually curated Agent architecture and AI Coding sources.",
5
5
  "private": false,
6
6
  "type": "module",