@folio-agent/handler 0.3.0 → 0.3.1

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/dist/init/cli.js CHANGED
@@ -1,44 +1,60 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, statSync } from "node:fs";
3
3
  import { mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import { dirname } from "node:path";
5
5
  import * as clack from "@clack/prompts";
6
6
  import { DEFAULT_WIZARD_ANSWERS, runWizard } from "./questions.js";
7
- import { appendIngestToBuildScript, buildApiRouteTemplate, buildConfigJson, buildThemeCss, deriveEndpointPath, upsertDevVar, } from "./writers.js";
7
+ import { appendIngestToBuildScript, buildApiRouteTemplate, buildConfigJson, buildThemeCss, deriveEndpointPath, ensureGitignoreEntry, PUBLIC_DIR_NAME, resolveThemeCssPath, THEME_CSS_FILENAME, upsertDevVar, } from "./writers.js";
8
8
  const CONFIG_PATH = "folio-agent.config.json";
9
- const THEME_CSS_PATH = "folio-agent.theme.css";
10
9
  const PACKAGE_JSON_PATH = "package.json";
11
10
  const DEV_VARS_PATH = ".dev.vars";
11
+ const GITIGNORE_PATH = ".gitignore";
12
12
  const KNOWLEDGE_OUTPUT_PATH = "dist/knowledge.json";
13
13
  const CLI_TEXT = {
14
14
  ja: {
15
15
  summaryTitle: "以下の内容で書き込みます:",
16
16
  summaryConfigLine: `${CONFIG_PATH} を生成・更新`,
17
- summaryThemeLine: `${THEME_CSS_PATH} を生成・更新(再実行時はこのファイルの書き換えだけで dev サーバーの HMR に反映されます)`,
17
+ summaryThemeLine: (path) => `${path} を生成・更新(再実行時はこのファイルの書き換えだけで dev サーバーの HMR に反映されます)`,
18
18
  summaryApiRouteLine: (path) => `${path} を新規生成`,
19
19
  summaryApiRouteSkippedLine: (path) => `${path} は既に存在するためスキップします`,
20
+ summaryApiRouteNotGeneratedLine: "API ルート雛形は生成しません(既存の配線を利用する設定)",
20
21
  summaryBuildScriptLine: `${PACKAGE_JSON_PATH} の build スクリプトに ingest コマンドを追記`,
21
22
  summaryBuildScriptUnchangedLine: `${PACKAGE_JSON_PATH} の build スクリプトは変更なし(既に含まれています)`,
22
23
  summaryDevVarsLine: `${DEV_VARS_PATH} に GEMINI_API_KEY を書き込み`,
24
+ summaryGitignoreAppendedLine: `${GITIGNORE_PATH} に ${DEV_VARS_PATH} を追記`,
25
+ summaryGitignoreAlreadyPresentLine: `${GITIGNORE_PATH} には ${DEV_VARS_PATH} が既に含まれています(追記不要)`,
23
26
  confirmMessage: "この内容で書き込みますか?",
24
27
  writeCancelledMessage: "書き込みを中止しました。",
25
28
  outroTitle: "セットアップが完了しました。",
26
- snippetIntro: "レイアウトに以下を1回だけ貼り付けてください(再実行時は不要です):",
29
+ snippetIntro: "サイトの全ページで読み込まれる共通テンプレート(Astro なら `src/layouts/` のレイアウト、素の HTML なら `</body>` の直前)に、以下を1回だけ貼り付けてください(再実行時は不要です):",
30
+ snippetIntroExistingConfigNote: "既に <folio-agent-widget> を導入済みの場合は、theme.css の読み込み(`<link>` または `import`)だけ追加すれば十分です。",
31
+ themeImportGuidanceNote: 'バンドラを使っているサイトでは、レイアウトのスクリプトで `import "./folio-agent.theme.css";` を読み込んでください(相対パスは配置に合わせて調整してください)。',
32
+ apiRouteNotGeneratedSnippetNote: "API ルート雛形は生成していません。endpoint は実際の配線パスに合わせて書き換えてください。",
33
+ gitignoreAppendedNote: `${GITIGNORE_PATH} に ${DEV_VARS_PATH} を追記しました。`,
34
+ gitignoreAlreadyPresentNote: `${GITIGNORE_PATH} には ${DEV_VARS_PATH} が既に含まれていました。`,
27
35
  d1Note: "D1 が未設定でも widget の表示とテーマ確認はローカルで動きます。チャット応答には D1 のセットアップと Gemini API キーが必要です。",
28
36
  },
29
37
  en: {
30
38
  summaryTitle: "The following will be written:",
31
39
  summaryConfigLine: `Generate/update ${CONFIG_PATH}`,
32
- summaryThemeLine: `Generate/update ${THEME_CSS_PATH} (re-running the wizard only rewrites this file, and your dev server's HMR picks it up)`,
40
+ summaryThemeLine: (path) => `Generate/update ${path} (re-running the wizard only rewrites this file, and your dev server's HMR picks it up)`,
33
41
  summaryApiRouteLine: (path) => `Generate ${path}`,
34
42
  summaryApiRouteSkippedLine: (path) => `Skip ${path} (already exists)`,
43
+ summaryApiRouteNotGeneratedLine: "Skip generating the API route scaffold (site already wires the handler up)",
35
44
  summaryBuildScriptLine: `Append the ingest command to ${PACKAGE_JSON_PATH}'s build script`,
36
45
  summaryBuildScriptUnchangedLine: `No change to ${PACKAGE_JSON_PATH}'s build script (already present)`,
37
46
  summaryDevVarsLine: `Write GEMINI_API_KEY to ${DEV_VARS_PATH}`,
47
+ summaryGitignoreAppendedLine: `Append ${DEV_VARS_PATH} to ${GITIGNORE_PATH}`,
48
+ summaryGitignoreAlreadyPresentLine: `${GITIGNORE_PATH} already contains ${DEV_VARS_PATH} (no change needed)`,
38
49
  confirmMessage: "Proceed with writing these files?",
39
50
  writeCancelledMessage: "Write cancelled.",
40
51
  outroTitle: "Setup complete.",
41
- snippetIntro: "Paste the following into your layout once (not needed again on re-runs):",
52
+ snippetIntro: "Paste the following once into the common template shared by every page (an Astro layout under `src/layouts/`, or right before `</body>` for plain HTML) — not needed again on re-runs:",
53
+ snippetIntroExistingConfigNote: "If <folio-agent-widget> is already set up, you only need to add the theme.css load (`<link>` or `import`).",
54
+ themeImportGuidanceNote: 'If your site uses a bundler, load it from your layout script with `import "./folio-agent.theme.css";` (adjust the relative path to your layout).',
55
+ apiRouteNotGeneratedSnippetNote: "The API route scaffold wasn't generated. Update the endpoint to match your actual routing.",
56
+ gitignoreAppendedNote: `Appended ${DEV_VARS_PATH} to ${GITIGNORE_PATH}.`,
57
+ gitignoreAlreadyPresentNote: `${GITIGNORE_PATH} already contained ${DEV_VARS_PATH}.`,
42
58
  d1Note: "Without D1 set up, the widget still renders and the theme can be checked locally. Chat responses require D1 and a Gemini API key.",
43
59
  },
44
60
  };
@@ -65,6 +81,7 @@ function seedDefaults(previous) {
65
81
  include: previous.include ?? DEFAULT_WIZARD_ANSWERS.include,
66
82
  zenn: previous.zenn,
67
83
  theme: previous.theme ?? DEFAULT_WIZARD_ANSWERS.theme,
84
+ apiRoutePath: undefined,
68
85
  };
69
86
  }
70
87
  async function main() {
@@ -73,15 +90,19 @@ async function main() {
73
90
  const text = CLI_TEXT[answers.language];
74
91
  const configJson = buildConfigJson(answers, previousConfig);
75
92
  const themeCss = buildThemeCss(answers.theme);
76
- const apiRouteExists = existsSync(answers.apiRoutePath);
77
- const apiRouteContent = apiRouteExists
78
- ? undefined
79
- : buildApiRouteTemplate({
93
+ const hasPublicDir = existsSync(PUBLIC_DIR_NAME) && statSync(PUBLIC_DIR_NAME).isDirectory();
94
+ const publicThemeCssPath = `${PUBLIC_DIR_NAME}/${THEME_CSS_FILENAME}`;
95
+ const themeCssPath = resolveThemeCssPath(hasPublicDir, existsSync(publicThemeCssPath), existsSync(THEME_CSS_FILENAME));
96
+ const usesPublicDirForTheme = themeCssPath === publicThemeCssPath;
97
+ const apiRouteExists = answers.apiRoutePath !== undefined && existsSync(answers.apiRoutePath);
98
+ const apiRouteContent = answers.apiRoutePath !== undefined && !apiRouteExists
99
+ ? buildApiRouteTemplate({
80
100
  apiRoutePath: answers.apiRoutePath,
81
101
  distDir: answers.distDir,
82
102
  contactUrl: answers.contactUrl,
83
103
  language: answers.language,
84
- });
104
+ })
105
+ : undefined;
85
106
  const pkg = (await readJson(PACKAGE_JSON_PATH)) ?? {};
86
107
  const scripts = pkg.scripts ?? {};
87
108
  const nextBuildScript = appendIngestToBuildScript(scripts.build, CONFIG_PATH, KNOWLEDGE_OUTPUT_PATH);
@@ -89,14 +110,20 @@ async function main() {
89
110
  const nextDevVars = answers.geminiApiKey
90
111
  ? upsertDevVar(await readTextOrEmpty(DEV_VARS_PATH), "GEMINI_API_KEY", answers.geminiApiKey)
91
112
  : undefined;
113
+ const gitignoreResult = nextDevVars !== undefined ? ensureGitignoreEntry(await readTextOrEmpty(GITIGNORE_PATH), DEV_VARS_PATH) : undefined;
92
114
  const summaryLines = [
93
115
  text.summaryConfigLine,
94
- text.summaryThemeLine,
95
- apiRouteExists ? text.summaryApiRouteSkippedLine(answers.apiRoutePath) : text.summaryApiRouteLine(answers.apiRoutePath),
116
+ text.summaryThemeLine(themeCssPath),
117
+ answers.apiRoutePath !== undefined
118
+ ? apiRouteExists
119
+ ? text.summaryApiRouteSkippedLine(answers.apiRoutePath)
120
+ : text.summaryApiRouteLine(answers.apiRoutePath)
121
+ : text.summaryApiRouteNotGeneratedLine,
96
122
  buildScriptChanged ? text.summaryBuildScriptLine : text.summaryBuildScriptUnchangedLine,
97
123
  ];
98
124
  if (nextDevVars !== undefined) {
99
125
  summaryLines.push(text.summaryDevVarsLine);
126
+ summaryLines.push(gitignoreResult?.changed ? text.summaryGitignoreAppendedLine : text.summaryGitignoreAlreadyPresentLine);
100
127
  }
101
128
  clack.note(summaryLines.join("\n"), text.summaryTitle);
102
129
  const shouldWrite = await clack.confirm({ message: text.confirmMessage, initialValue: true });
@@ -105,8 +132,8 @@ async function main() {
105
132
  return;
106
133
  }
107
134
  await writeFile(CONFIG_PATH, `${JSON.stringify(configJson, null, 2)}\n`);
108
- await writeFile(THEME_CSS_PATH, themeCss);
109
- if (apiRouteContent) {
135
+ await writeFile(themeCssPath, themeCss);
136
+ if (apiRouteContent !== undefined && answers.apiRoutePath !== undefined) {
110
137
  await mkdir(dirname(answers.apiRoutePath), { recursive: true });
111
138
  await writeFile(answers.apiRoutePath, apiRouteContent);
112
139
  }
@@ -116,17 +143,35 @@ async function main() {
116
143
  }
117
144
  if (nextDevVars !== undefined) {
118
145
  await writeFile(DEV_VARS_PATH, nextDevVars);
146
+ if (gitignoreResult?.changed) {
147
+ await writeFile(GITIGNORE_PATH, gitignoreResult.content);
148
+ }
119
149
  }
120
- const endpoint = deriveEndpointPath(answers.apiRoutePath);
121
- const snippet = [
122
- `<link rel="stylesheet" href="/${THEME_CSS_PATH}">`,
150
+ const endpoint = deriveEndpointPath(answers.apiRoutePath ?? DEFAULT_WIZARD_ANSWERS.apiRoutePath ?? "functions/api/chat.ts");
151
+ const snippetLines = [
152
+ ...(usesPublicDirForTheme ? [`<link rel="stylesheet" href="/${THEME_CSS_FILENAME}">`] : []),
123
153
  `<folio-agent-widget endpoint="${endpoint}" policy-href="/data-policy" lang="${answers.language}"></folio-agent-widget>`,
124
154
  '<script type="module">',
125
155
  ' import { defineFolioAgentWidget } from "@folio-agent/widget";',
126
156
  " defineFolioAgentWidget();",
127
157
  "</script>",
128
- ].join("\n");
129
- clack.note(snippet, text.snippetIntro);
158
+ ];
159
+ const snippet = snippetLines.join("\n");
160
+ const introNotes = [text.snippetIntro];
161
+ if (previousConfig) {
162
+ introNotes.push(text.snippetIntroExistingConfigNote);
163
+ }
164
+ if (!usesPublicDirForTheme) {
165
+ introNotes.push(text.themeImportGuidanceNote);
166
+ }
167
+ if (answers.apiRoutePath === undefined) {
168
+ introNotes.push(text.apiRouteNotGeneratedSnippetNote);
169
+ }
170
+ clack.note(introNotes.join("\n\n"));
171
+ console.log(`\n${snippet}\n`);
172
+ if (nextDevVars !== undefined) {
173
+ clack.note(gitignoreResult?.changed ? text.gitignoreAppendedNote : text.gitignoreAlreadyPresentNote);
174
+ }
130
175
  clack.note(text.d1Note);
131
176
  clack.outro(text.outroTitle);
132
177
  }
@@ -9,12 +9,24 @@ export interface WizardAnswers {
9
9
  theme: ThemeColors;
10
10
  /** Non-empty only when the visitor typed a new key this run; blank means "leave .dev.vars untouched". */
11
11
  geminiApiKey?: string;
12
- apiRoutePath: string;
12
+ /** `undefined` means "don't generate the API route scaffold" (site already wires the handler elsewhere). */
13
+ apiRoutePath?: string;
13
14
  }
14
15
  export declare const DEFAULT_WIZARD_ANSWERS: WizardAnswers;
15
16
  /** Pure hex color validation used both as a `clack.text` validator and in tests. */
16
17
  export declare function isValidHexColor(value: string): boolean;
17
18
  /** Splits a comma-separated glob list into a trimmed, non-empty array. */
18
19
  export declare function parseIncludeList(value: string): string[];
20
+ /**
21
+ * Normalizes a Zenn `baseUrl` answer. A bare username (no `http(s)://` prefix) is expanded to the
22
+ * canonical Zenn articles URL; an already-fully-qualified URL passes through unchanged.
23
+ */
24
+ export declare function normalizeZennBaseUrl(input: string): string;
25
+ /**
26
+ * Default answer for the "API route scaffold output path" question: the scaffold path for a
27
+ * fresh setup (no previous config), or `undefined` ("don't generate") when re-running against an
28
+ * existing config, since the site likely already wires the handler up some other way.
29
+ */
30
+ export declare function defaultApiRoutePath(hasPreviousConfig: boolean): string | undefined;
19
31
  /** Runs the interactive wizard (all-defaults-Enter-to-quick-mode) and returns the collected answers. */
20
32
  export declare function runWizard(defaults: WizardAnswers): Promise<WizardAnswers>;
@@ -1,11 +1,12 @@
1
1
  import * as clack from "@clack/prompts";
2
2
  import { DEFAULT_LANGUAGE } from "../chat/types.js";
3
+ const DEFAULT_API_ROUTE_PATH = "functions/api/chat.ts";
3
4
  export const DEFAULT_WIZARD_ANSWERS = {
4
5
  language: DEFAULT_LANGUAGE,
5
6
  distDir: "dist",
6
7
  include: ["/**"],
7
8
  theme: { accent: "#2563eb", surface: "#ffffff", text: "#111827" },
8
- apiRoutePath: "functions/api/chat.ts",
9
+ apiRoutePath: DEFAULT_API_ROUTE_PATH,
9
10
  };
10
11
  const HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
11
12
  /** Pure hex color validation used both as a `clack.text` validator and in tests. */
@@ -19,6 +20,22 @@ export function parseIncludeList(value) {
19
20
  .map((item) => item.trim())
20
21
  .filter((item) => item.length > 0);
21
22
  }
23
+ /**
24
+ * Normalizes a Zenn `baseUrl` answer. A bare username (no `http(s)://` prefix) is expanded to the
25
+ * canonical Zenn articles URL; an already-fully-qualified URL passes through unchanged.
26
+ */
27
+ export function normalizeZennBaseUrl(input) {
28
+ const trimmed = input.trim();
29
+ return /^https?:\/\//i.test(trimmed) ? trimmed : `https://zenn.dev/${trimmed}/articles`;
30
+ }
31
+ /**
32
+ * Default answer for the "API route scaffold output path" question: the scaffold path for a
33
+ * fresh setup (no previous config), or `undefined` ("don't generate") when re-running against an
34
+ * existing config, since the site likely already wires the handler up some other way.
35
+ */
36
+ export function defaultApiRoutePath(hasPreviousConfig) {
37
+ return hasPreviousConfig ? undefined : DEFAULT_API_ROUTE_PATH;
38
+ }
22
39
  const WIZARD_TEXT = {
23
40
  ja: {
24
41
  distDirMessage: "ビルド成果物のディレクトリ(distDir)",
@@ -26,7 +43,8 @@ const WIZARD_TEXT = {
26
43
  includePlaceholder: "/**",
27
44
  zennConfirmMessage: "Zenn 記事も知識に含めますか?",
28
45
  zennArticlesDirMessage: "Zenn CLI の articles/ ディレクトリへのパス",
29
- zennBaseUrlMessage: "記事の公開 URL のベース(例: https://zenn.dev/<username>/articles)",
46
+ zennBaseUrlMessage: "記事の公開 URL のベース(例: https://zenn.dev/<username>/articles、またはユーザー名のみでも可)",
47
+ zennBaseUrlNormalizedNote: (baseUrl) => `✅ ${baseUrl} として登録します。`,
30
48
  contactUrlMessage: "Contact ページの URL(任意。空 Enter でスキップ)",
31
49
  contactUrlPlaceholder: "https://example.com/contact",
32
50
  accentMessage: "テーマカラー: accent(#付き hex)",
@@ -35,7 +53,8 @@ const WIZARD_TEXT = {
35
53
  colorValidationError: "#fff や #ffffff のような hex カラーコードで入力してください。",
36
54
  geminiApiKeyMessage: "Gemini API キー(任意。空 Enter でスキップ)",
37
55
  geminiApiKeySkipNote: "スキップしました。キーは https://aistudio.google.com/apikey から取得できます。",
38
- apiRoutePathMessage: "API ルート雛形の出力先",
56
+ apiRoutePathMessage: "API ルート雛形の出力先(空 Enter で生成しない。既に別の経路で配線済みのサイト向け)",
57
+ apiRoutePathPlaceholder: "functions/api/chat.ts",
39
58
  cancelledMessage: "セットアップを中止しました。",
40
59
  },
41
60
  en: {
@@ -44,7 +63,8 @@ const WIZARD_TEXT = {
44
63
  includePlaceholder: "/**",
45
64
  zennConfirmMessage: "Include Zenn articles in the knowledge too?",
46
65
  zennArticlesDirMessage: "Path to the Zenn CLI articles/ directory",
47
- zennBaseUrlMessage: "Base URL for published articles (e.g. https://zenn.dev/<username>/articles)",
66
+ zennBaseUrlMessage: "Base URL for published articles (e.g. https://zenn.dev/<username>/articles, or just your username)",
67
+ zennBaseUrlNormalizedNote: (baseUrl) => `✅ Registering as ${baseUrl}.`,
48
68
  contactUrlMessage: "Contact page URL (optional, press Enter to skip)",
49
69
  contactUrlPlaceholder: "https://example.com/contact",
50
70
  accentMessage: "Theme color: accent (hex with #)",
@@ -53,7 +73,8 @@ const WIZARD_TEXT = {
53
73
  colorValidationError: "Enter a hex color code like #fff or #ffffff.",
54
74
  geminiApiKeyMessage: "Gemini API key (optional, press Enter to skip)",
55
75
  geminiApiKeySkipNote: "Skipped. Get a key at https://aistudio.google.com/apikey",
56
- apiRoutePathMessage: "Output path for the API route scaffold",
76
+ apiRoutePathMessage: "Output path for the API route scaffold (blank to skip generating it, e.g. if already wired up elsewhere)",
77
+ apiRoutePathPlaceholder: "functions/api/chat.ts",
57
78
  cancelledMessage: "Setup cancelled.",
58
79
  },
59
80
  };
@@ -90,10 +111,14 @@ export async function runWizard(defaults) {
90
111
  message: text.zennArticlesDirMessage,
91
112
  initialValue: defaults.zenn?.articlesDir ?? "../zenn-content/articles",
92
113
  }), text.cancelledMessage);
93
- const baseUrl = unwrap(await clack.text({
114
+ const baseUrlInput = unwrap(await clack.text({
94
115
  message: text.zennBaseUrlMessage,
95
116
  initialValue: defaults.zenn?.baseUrl ?? "",
96
117
  }), text.cancelledMessage);
118
+ const baseUrl = normalizeZennBaseUrl(baseUrlInput);
119
+ if (baseUrl !== baseUrlInput.trim()) {
120
+ clack.note(text.zennBaseUrlNormalizedNote(baseUrl));
121
+ }
97
122
  zenn = { articlesDir, baseUrl };
98
123
  }
99
124
  const contactUrlInput = unwrap(await clack.text({
@@ -111,7 +136,12 @@ export async function runWizard(defaults) {
111
136
  if (!geminiApiKey) {
112
137
  clack.note(text.geminiApiKeySkipNote);
113
138
  }
114
- const apiRoutePath = unwrap(await clack.text({ message: text.apiRoutePathMessage, initialValue: defaults.apiRoutePath }), text.cancelledMessage);
139
+ const apiRoutePathInput = unwrap(await clack.text({
140
+ message: text.apiRoutePathMessage,
141
+ placeholder: text.apiRoutePathPlaceholder,
142
+ initialValue: defaults.apiRoutePath ?? "",
143
+ }), text.cancelledMessage);
144
+ const apiRoutePath = apiRoutePathInput.trim().length > 0 ? apiRoutePathInput.trim() : undefined;
115
145
  return {
116
146
  language,
117
147
  distDir,
@@ -1,10 +1,19 @@
1
1
  import type { Language } from "../chat/types.js";
2
2
  import type { IngestConfig, ThemeColors } from "../ingest/types.js";
3
3
  import type { WizardAnswers } from "./questions.js";
4
+ export declare const THEME_CSS_FILENAME = "folio-agent.theme.css";
5
+ export declare const PUBLIC_DIR_NAME = "public";
4
6
  /** Builds `folio-agent.config.json` content, preserving fields the wizard never asks about. */
5
7
  export declare function buildConfigJson(answers: WizardAnswers, previous?: IngestConfig): IngestConfig;
6
8
  /** Builds `folio-agent.theme.css` content. Re-running the wizard only rewrites this file, which HMR picks up. */
7
9
  export declare function buildThemeCss(theme: ThemeColors): string;
10
+ /**
11
+ * Decides where `folio-agent.theme.css` should be written. A file outside `public/` (or its
12
+ * framework equivalent) isn't served by most static-site pipelines (Astro, Next, etc.), so a
13
+ * `public/` directory is preferred when present. Re-runs reuse whichever location already holds
14
+ * the file, so switching `public/` in and out of existence mid-project doesn't orphan a stale copy.
15
+ */
16
+ export declare function resolveThemeCssPath(hasPublicDir: boolean, existingPublicFile: boolean, existingRootFile: boolean): string;
8
17
  /**
9
18
  * Computes the relative import specifier from the API route file to `<distDir>/knowledge.json`,
10
19
  * independent of the OS path separator (config values are always POSIX-style project-relative paths).
@@ -24,3 +33,12 @@ export declare function buildApiRouteTemplate(answers: ApiRouteAnswers): string;
24
33
  export declare function appendIngestToBuildScript(buildScript: string | undefined, configPath: string, knowledgeOutputPath: string): string;
25
34
  /** Adds or replaces a single `KEY=value` line in `.dev.vars` content, leaving other lines untouched. */
26
35
  export declare function upsertDevVar(content: string, key: string, value: string): string;
36
+ /**
37
+ * Ensures `entry` appears as its own line in `.gitignore` content, appending it if no existing
38
+ * line starts with it. Intentionally doesn't interpret comments or wider patterns (e.g. `*.vars`);
39
+ * a duplicate append is harmless, so a plain line-start check is enough.
40
+ */
41
+ export declare function ensureGitignoreEntry(content: string, entry: string): {
42
+ content: string;
43
+ changed: boolean;
44
+ };
@@ -1,6 +1,8 @@
1
1
  import { posix } from "node:path";
2
2
  import { DEFAULT_LANGUAGE } from "../chat/types.js";
3
3
  const INGEST_COMMAND_PREFIX = "folio-agent-ingest";
4
+ export const THEME_CSS_FILENAME = "folio-agent.theme.css";
5
+ export const PUBLIC_DIR_NAME = "public";
4
6
  /** Builds `folio-agent.config.json` content, preserving fields the wizard never asks about. */
5
7
  export function buildConfigJson(answers, previous) {
6
8
  const config = {
@@ -34,6 +36,19 @@ export function buildThemeCss(theme) {
34
36
  "",
35
37
  ].join("\n");
36
38
  }
39
+ /**
40
+ * Decides where `folio-agent.theme.css` should be written. A file outside `public/` (or its
41
+ * framework equivalent) isn't served by most static-site pipelines (Astro, Next, etc.), so a
42
+ * `public/` directory is preferred when present. Re-runs reuse whichever location already holds
43
+ * the file, so switching `public/` in and out of existence mid-project doesn't orphan a stale copy.
44
+ */
45
+ export function resolveThemeCssPath(hasPublicDir, existingPublicFile, existingRootFile) {
46
+ if (existingPublicFile)
47
+ return `${PUBLIC_DIR_NAME}/${THEME_CSS_FILENAME}`;
48
+ if (existingRootFile)
49
+ return THEME_CSS_FILENAME;
50
+ return hasPublicDir ? `${PUBLIC_DIR_NAME}/${THEME_CSS_FILENAME}` : THEME_CSS_FILENAME;
51
+ }
37
52
  /**
38
53
  * Computes the relative import specifier from the API route file to `<distDir>/knowledge.json`,
39
54
  * independent of the OS path separator (config values are always POSIX-style project-relative paths).
@@ -103,3 +118,18 @@ export function upsertDevVar(content, key, value) {
103
118
  }
104
119
  return `${lines.join("\n")}\n`;
105
120
  }
121
+ /**
122
+ * Ensures `entry` appears as its own line in `.gitignore` content, appending it if no existing
123
+ * line starts with it. Intentionally doesn't interpret comments or wider patterns (e.g. `*.vars`);
124
+ * a duplicate append is harmless, so a plain line-start check is enough.
125
+ */
126
+ export function ensureGitignoreEntry(content, entry) {
127
+ const lines = content.length > 0 ? content.split("\n") : [];
128
+ const alreadyPresent = lines.some((line) => line.startsWith(entry));
129
+ if (alreadyPresent) {
130
+ return { content, changed: false };
131
+ }
132
+ const trimmed = content.replace(/\n+$/, "");
133
+ const nextContent = trimmed.length > 0 ? `${trimmed}\n${entry}\n` : `${entry}\n`;
134
+ return { content: nextContent, changed: true };
135
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@folio-agent/handler",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Chat handler (LangGraph.js on Cloudflare Workers) + knowledge ingestion CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",