@larkup/cli 0.2.32 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +11 -1
  2. package/dist/index.js +211 -9
  3. package/package.json +6 -3
package/README.md CHANGED
@@ -12,11 +12,20 @@ npm install -g @larkup/cli
12
12
 
13
13
  ```bash
14
14
  larkup init product-docs
15
+ larkup ai-model --embedding openai/text-embedding-3-small --api-key "$OPENAI_API_KEY"
15
16
  larkup index ./knowledge
16
17
  larkup dev
17
18
  ```
18
19
 
19
- `index` accepts individual files or folders, including text, source code, CSV, JSON, PDF, and Word documents. It streams loading and indexing progress.
20
+ `index` accepts individual files or folders, including text, source code, CSV, JSON, PDF, and Word documents. It streams loading and indexing progress, and writes the same Project state shown in the Web UI.
21
+
22
+ ```bash
23
+ larkup index ./folder --pdf
24
+ larkup index ./folder --media
25
+ larkup index ./folder --video
26
+ larkup index ./folder --extension .json,.pdf
27
+ larkup ai-model list --type embedding
28
+ ```
20
29
 
21
30
  Use the dedicated media command for images, audio, and video:
22
31
 
@@ -28,6 +37,7 @@ larkup media ./demo.mp4
28
37
  ## Commands
29
38
 
30
39
  - `larkup index [sources...]` loads sources and builds the index.
40
+ - `larkup ai-model [list]` lists or configures embedding, chat, and vision models.
31
41
  - `larkup dev [name]` creates a workspace (default: `my-larkup`) and runs its local server.
32
42
  - `larkup documents` manages and exports corpus documents.
33
43
  - `larkup media [sources...]` processes image, audio, and video files.
package/dist/index.js CHANGED
@@ -36,7 +36,7 @@ import { Command } from "commander";
36
36
  // package.json
37
37
  var package_default = {
38
38
  name: "@larkup/cli",
39
- version: "0.2.32",
39
+ version: "0.3.0",
40
40
  engines: {
41
41
  node: ">=22"
42
42
  },
@@ -69,6 +69,7 @@ var package_default = {
69
69
  "@larkup/core": "workspace:*",
70
70
  "@larkup/marketplace": "workspace:*",
71
71
  "@larkup/vector-stores": "workspace:*",
72
+ "@napi-rs/canvas": "1.0.8",
72
73
  "@pinecone-database/pinecone": "^8.1.0",
73
74
  ai: "^7.0.91",
74
75
  "apache-arrow": "18.1.0",
@@ -76,6 +77,8 @@ var package_default = {
76
77
  chromadb: "^3.5.0",
77
78
  "cli-table3": "^0.6.5",
78
79
  commander: "^15.0.0",
80
+ mammoth: "^1.12.0",
81
+ "pdf-parse": "2.4.5",
79
82
  zod: "^4.4.3"
80
83
  },
81
84
  devDependencies: {
@@ -4538,6 +4541,8 @@ function providerEnvironmentKey(provider) {
4538
4541
  // src/lib/local-files.ts
4539
4542
  import { promises as fs7 } from "fs";
4540
4543
  import path9 from "path";
4544
+ import mammoth from "mammoth";
4545
+ import { getData } from "pdf-parse/worker";
4541
4546
  var TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
4542
4547
  ".txt",
4543
4548
  ".md",
@@ -4569,6 +4574,11 @@ var TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
4569
4574
  ".sh",
4570
4575
  ".css"
4571
4576
  ]);
4577
+ var PDF_EXTENSIONS = /* @__PURE__ */ new Set([".pdf"]);
4578
+ var WORD_EXTENSIONS = /* @__PURE__ */ new Set([".docx"]);
4579
+ var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".webp", ".gif"]);
4580
+ var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([".mp4", ".webm", ".mov", ".mkv"]);
4581
+ var AUDIO_EXTENSIONS = /* @__PURE__ */ new Set([".mp3", ".m4a", ".wav", ".ogg", ".flac"]);
4572
4582
  var MEDIA_EXTENSIONS = /* @__PURE__ */ new Set([
4573
4583
  ".png",
4574
4584
  ".jpg",
@@ -4586,9 +4596,52 @@ var MEDIA_EXTENSIONS = /* @__PURE__ */ new Set([
4586
4596
  ".flac"
4587
4597
  ]);
4588
4598
  var SKIPPED_DIRECTORIES = /* @__PURE__ */ new Set([".git", ".larkup", "node_modules", "dist", "build"]);
4599
+ var pdfParserPromise;
4600
+ function extensionOf(filePath) {
4601
+ return path9.extname(filePath).toLowerCase();
4602
+ }
4603
+ function normalizedExtension(extension) {
4604
+ const value = extension.trim().toLowerCase();
4605
+ return value ? value.startsWith(".") ? value : `.${value}` : "";
4606
+ }
4607
+ async function getPdfParser() {
4608
+ if (!pdfParserPromise) {
4609
+ pdfParserPromise = (async () => {
4610
+ const canvas = await import("@napi-rs/canvas");
4611
+ Object.assign(globalThis, {
4612
+ DOMMatrix: canvas.DOMMatrix,
4613
+ ImageData: canvas.ImageData,
4614
+ Path2D: canvas.Path2D
4615
+ });
4616
+ const { PDFParse } = await import("pdf-parse");
4617
+ PDFParse.setWorker(getData());
4618
+ return PDFParse;
4619
+ })();
4620
+ }
4621
+ return pdfParserPromise;
4622
+ }
4589
4623
  function isMediaPath(filePath) {
4590
4624
  return MEDIA_EXTENSIONS.has(path9.extname(filePath).toLowerCase());
4591
4625
  }
4626
+ function filterLocalFiles(files, filter = {}) {
4627
+ const requestedExtensions = new Set(
4628
+ (filter.extensions ?? []).map(normalizedExtension).filter(Boolean)
4629
+ );
4630
+ const requestedKinds = new Set(filter.kinds ?? []);
4631
+ if (requestedExtensions.size === 0 && requestedKinds.size === 0) return files;
4632
+ return files.filter((filePath) => {
4633
+ const extension = extensionOf(filePath);
4634
+ if (requestedExtensions.has(extension)) return true;
4635
+ if (requestedKinds.has("media") && MEDIA_EXTENSIONS.has(extension)) return true;
4636
+ if (requestedKinds.has("image") && IMAGE_EXTENSIONS.has(extension)) return true;
4637
+ if (requestedKinds.has("audio") && AUDIO_EXTENSIONS.has(extension)) return true;
4638
+ if (requestedKinds.has("video") && VIDEO_EXTENSIONS.has(extension)) return true;
4639
+ if (requestedKinds.has("pdf") && PDF_EXTENSIONS.has(extension)) return true;
4640
+ if (requestedKinds.has("json") && (extension === ".json" || extension === ".jsonl"))
4641
+ return true;
4642
+ return requestedKinds.has("text") && TEXT_EXTENSIONS.has(extension);
4643
+ });
4644
+ }
4592
4645
  async function collectFiles(inputs) {
4593
4646
  const files = [];
4594
4647
  for (const input of inputs) {
@@ -4617,8 +4670,22 @@ async function walk(directory, files) {
4617
4670
  async function readTextFiles(files) {
4618
4671
  const documents = [];
4619
4672
  for (const filePath of files) {
4620
- if (!TEXT_EXTENSIONS.has(path9.extname(filePath).toLowerCase())) continue;
4621
- const content = await fs7.readFile(filePath, "utf8");
4673
+ const extension = extensionOf(filePath);
4674
+ let content;
4675
+ if (PDF_EXTENSIONS.has(extension)) {
4676
+ const PDFParse = await getPdfParser();
4677
+ const parser = new PDFParse({ data: new Uint8Array(await fs7.readFile(filePath)) });
4678
+ try {
4679
+ content = (await parser.getText()).text;
4680
+ } finally {
4681
+ await parser.destroy();
4682
+ }
4683
+ } else if (WORD_EXTENSIONS.has(extension)) {
4684
+ content = (await mammoth.extractRawText({ buffer: await fs7.readFile(filePath) })).value;
4685
+ } else {
4686
+ if (!TEXT_EXTENSIONS.has(extension)) continue;
4687
+ content = await fs7.readFile(filePath, "utf8");
4688
+ }
4622
4689
  if (!content.trim() || content.includes("\0")) continue;
4623
4690
  documents.push({
4624
4691
  path: filePath,
@@ -5897,13 +5964,34 @@ function deriveCliAudioSignals(chunks) {
5897
5964
  }
5898
5965
 
5899
5966
  // src/commands/index-cmd.ts
5967
+ function selectedKinds(options) {
5968
+ return [
5969
+ ...options.pdf ? ["pdf"] : [],
5970
+ ...options.json ? ["json"] : [],
5971
+ ...options.media ? ["media"] : [],
5972
+ ...options.image ? ["image"] : [],
5973
+ ...options.audio ? ["audio"] : [],
5974
+ ...options.video ? ["video"] : []
5975
+ ];
5976
+ }
5977
+ function progressBar(current, total) {
5978
+ if (total <= 0) return "Preparing chunks";
5979
+ const width = 24;
5980
+ const completed = Math.min(width, Math.round(current / total * width));
5981
+ const percentage = Math.min(100, Math.round(current / total * 100));
5982
+ return `[${"\u2588".repeat(completed)}${"\u2591".repeat(width - completed)}] ${percentage}%`;
5983
+ }
5900
5984
  async function indexCommand(inputs, options) {
5901
5985
  await inProjectScope(options.project, async () => {
5902
5986
  await requireActiveProject();
5903
5987
  if (inputs.length > 0) {
5904
5988
  const files = await collectFiles(inputs);
5905
- const textFiles = await readTextFiles(files);
5906
- const mediaFiles = files.filter(isMediaPath);
5989
+ const selected = filterLocalFiles(files, {
5990
+ kinds: selectedKinds(options),
5991
+ extensions: options.extension?.split(",")
5992
+ });
5993
+ const textFiles = await readTextFiles(selected);
5994
+ const mediaFiles = selected.filter(isMediaPath);
5907
5995
  for (const document of textFiles) {
5908
5996
  await addDocument({ title: document.title, content: document.content, source: "files" });
5909
5997
  }
@@ -5939,7 +6027,9 @@ async function indexCommand(inputs, options) {
5939
6027
  const r = await readRun();
5940
6028
  if (!r || done) return;
5941
6029
  if (r.totalChunks > 0) {
5942
- s.message(`${r.status.padEnd(10)} ${r.processedChunks}/${r.totalChunks} chunks`);
6030
+ s.message(
6031
+ `${r.status.padEnd(10)} ${progressBar(r.processedChunks, r.totalChunks)} ${r.processedChunks}/${r.totalChunks} chunks`
6032
+ );
5943
6033
  } else {
5944
6034
  s.message(`${r.status}\u2026`);
5945
6035
  }
@@ -6218,8 +6308,8 @@ async function chatCommand(options) {
6218
6308
  const provider = config.chatProvider || config.embeddingProvider;
6219
6309
  const chatModels = (await getAllModels()).filter((model) => model.type === "language").map(toChatDescriptor);
6220
6310
  const chatModelId = options.model || config.chatModelId || getDefaultChatModel(chatModels, provider)?.id || "openai/gpt-4o-mini";
6221
- const modelProvider = chatModelId.startsWith("custom:") ? "custom" : chatModelId.includes("/") ? chatModelId.split("/", 1)[0] : provider;
6222
- if (provider !== "vercel_ai_gateway" && modelProvider !== provider) {
6311
+ const modelProvider2 = chatModelId.startsWith("custom:") ? "custom" : chatModelId.includes("/") ? chatModelId.split("/", 1)[0] : provider;
6312
+ if (provider !== "vercel_ai_gateway" && modelProvider2 !== provider) {
6223
6313
  throw new Error(
6224
6314
  `Model "${chatModelId}" is not available through the configured ${provider} provider. Choose a matching model or change the chat provider with \`larkup settings\`.`
6225
6315
  );
@@ -6954,6 +7044,101 @@ async function installCommand(options) {
6954
7044
  if (notEntitled.length) log.error(`Not entitled for this organization: ${notEntitled.join(", ")}`);
6955
7045
  }
6956
7046
 
7047
+ // src/commands/ai-model.ts
7048
+ function modelProvider(modelId) {
7049
+ return modelId.split("/")[0] || "openai";
7050
+ }
7051
+ function supportsVision(model) {
7052
+ return model.type === "language" && Boolean(model.tags?.includes("vision"));
7053
+ }
7054
+ function printModel(model) {
7055
+ log.info(`${model.id}${model.name && model.name !== model.id ? ` ${model.name}` : ""}`);
7056
+ if (model.description) log.dim(` ${model.description}`);
7057
+ }
7058
+ async function listModels(type) {
7059
+ const models = await getAllModels();
7060
+ const selectedType = type ?? "embedding";
7061
+ if (selectedType === "embedding") {
7062
+ for (const model of EMBEDDING_MODELS) printModel({ ...model, name: model.label });
7063
+ return;
7064
+ }
7065
+ const matching = models.filter(
7066
+ selectedType === "vision" ? supportsVision : (model) => model.type === "language" && model.tags?.includes("tool-use")
7067
+ );
7068
+ for (const model of matching) printModel(model);
7069
+ }
7070
+ function validateLanguageModel(models, modelId, capability) {
7071
+ const model = models.find((candidate) => candidate.id === modelId);
7072
+ if (!model || model.type !== "language") {
7073
+ throw new Error(
7074
+ `Unknown ${capability} model "${modelId}". Run: larkup ai-model list --type ${capability}`
7075
+ );
7076
+ }
7077
+ if (capability === "vision" && !supportsVision(model)) {
7078
+ throw new Error(
7079
+ `Model "${modelId}" is not vision-capable. Run: larkup ai-model list --type vision`
7080
+ );
7081
+ }
7082
+ return model;
7083
+ }
7084
+ async function aiModelCommand(action, options) {
7085
+ await inProjectScope(options.project, async () => {
7086
+ await requireActiveProject();
7087
+ if (action === "list") {
7088
+ await listModels(options.type);
7089
+ return;
7090
+ }
7091
+ if (action && action !== "show") {
7092
+ throw new Error(
7093
+ `Unknown ai-model action "${action}". Use "list" or omit the action to configure models.`
7094
+ );
7095
+ }
7096
+ const config = await readConfig();
7097
+ const apiKey = options.apiKey ?? options.apikey;
7098
+ if (!options.embedding && !options.chat && !options.vision) {
7099
+ if (apiKey) {
7100
+ await writeConfig({ ...config, embeddingApiKey: apiKey });
7101
+ log.success("Embedding API key saved for the active Project.");
7102
+ return;
7103
+ }
7104
+ log.info(`Embedding ${config.embeddingModelId}`);
7105
+ log.info(`Chat ${config.chatModelId ?? "not configured"}`);
7106
+ log.info(`Vision ${config.visionModelId ?? "not configured"}`);
7107
+ log.dim(
7108
+ "Set one with: larkup ai-model --embedding openai/text-embedding-3-small --api-key <key>"
7109
+ );
7110
+ return;
7111
+ }
7112
+ const models = await getAllModels();
7113
+ const next = { ...config };
7114
+ if (options.embedding) {
7115
+ const embedding = getEmbeddingModel(options.embedding);
7116
+ if (!embedding) {
7117
+ throw new Error(
7118
+ `Unknown embedding model "${options.embedding}". Run: larkup ai-model list --type embedding`
7119
+ );
7120
+ }
7121
+ next.embeddingModelId = embedding.id;
7122
+ next.embeddingProvider = embedding.provider;
7123
+ if (apiKey) next.embeddingApiKey = apiKey;
7124
+ }
7125
+ if (options.chat) {
7126
+ const chat = validateLanguageModel(models, options.chat, "chat");
7127
+ next.chatModelId = chat.id;
7128
+ next.chatProvider = modelProvider(chat.id);
7129
+ if (apiKey) next.chatApiKey = apiKey;
7130
+ }
7131
+ if (options.vision) {
7132
+ const vision = validateLanguageModel(models, options.vision, "vision");
7133
+ next.visionModelId = vision.id;
7134
+ next.visionProvider = modelProvider(vision.id);
7135
+ if (apiKey) next.visionApiKey = apiKey;
7136
+ }
7137
+ await writeConfig(next);
7138
+ log.success("AI model settings saved. The Web UI now uses the same Project configuration.");
7139
+ });
7140
+ }
7141
+
6957
7142
  // src/index.ts
6958
7143
  var program = new Command();
6959
7144
  program.name("larkup").description("Build, index, and serve a RAG pipeline from the terminal.").version(package_default.version);
@@ -6985,9 +7170,26 @@ program.command("add-doc").description("Add a document to the corpus").option("-
6985
7170
  program.command("documents [action] [idOrPath]").alias("docs").description("List, show, update, remove, or export corpus documents").option("--title <title>", "Set a document title when updating").option("--text <content>", "Set document content when updating").option("--file <path>", "Read updated document content from a file").option("--url <url>", "Set the source URL when updating").option("--format <csv|jsonl>", "Corpus export format", "csv").option("--out <path>", "Corpus export destination").option("--project <id>", "Target a specific Project instead of the active one").action(async (action, idOrPath, options) => {
6986
7171
  await documentsCommand(action, idOrPath, options);
6987
7172
  });
6988
- program.command("index [sources...]").description("Load files or folders, then chunk \u2192 embed \u2192 store the corpus").option("--project <id>", "Target a specific Project instead of the active one").option("--no-run", "Load supplied sources without building the index").option("--incremental", "Index only documents added since the last completed run").action(async (sources, options) => {
7173
+ program.command("index [sources...]").description("Load files or folders, then chunk \u2192 embed \u2192 store the corpus").option("--project <id>", "Target a specific Project instead of the active one").option("--no-run", "Load supplied sources without building the index").option("--incremental", "Index only documents added since the last completed run").option("--pdf", "Index PDF files only").option("--json", "Index JSON and JSONL files only").option("--media", "Index images, audio, and video only").option("--image", "Index image files only").option("--audio", "Index audio files only").option("--video", "Index video files only").option("--extension <extensions>", "Comma-separated extensions, for example .json,.pdf").action(async (sources, options) => {
7174
+ await indexCommand(sources ?? [], options);
7175
+ });
7176
+ program.command("indexing [sources...]").description("Alias for index").option("--project <id>", "Target a specific Project instead of the active one").option("--no-run", "Load supplied sources without building the index").option("--incremental", "Index only documents added since the last completed run").option("--pdf", "Index PDF files only").option("--json", "Index JSON and JSONL files only").option("--media", "Index images, audio, and video only").option("--image", "Index image files only").option("--audio", "Index audio files only").option("--video", "Index video files only").option("--extension <extensions>", "Comma-separated extensions, for example .json,.pdf").action(async (sources, options) => {
6989
7177
  await indexCommand(sources ?? [], options);
6990
7178
  });
7179
+ function configureAiModelCommand(command) {
7180
+ return command.option("--embedding <model>", "Set the embedding model ID").option("--chat <model>", "Set the chat model ID").option("--vision <model>", "Set the vision model ID").option("--api-key <key>", "Set the API key for the selected model type(s)").option("--apikey <key>", "Alias for --api-key").option("--project <id>", "Target a specific Project instead of the active one").option("--type <embedding|chat|vision>", "Filter model list by capability");
7181
+ }
7182
+ configureAiModelCommand(
7183
+ program.command("ai-model [action]").description("List or configure embedding, chat, and vision AI models")
7184
+ ).action(async (action, options) => {
7185
+ await aiModelCommand(action, options);
7186
+ });
7187
+ var setCommand = program.command("set").description("Set Project configuration values");
7188
+ configureAiModelCommand(
7189
+ setCommand.command("ai-model").description("Configure AI models (alias for ai-model)")
7190
+ ).action(async (options) => {
7191
+ await aiModelCommand(void 0, options);
7192
+ });
6991
7193
  program.command("media [sources...]").description("Add image, audio, or video files and stream processing progress").option("--no-index", "Process media without rebuilding the index").option("--project <id>", "Target a specific Project instead of the active one").action(async (sources, options) => {
6992
7194
  await mediaCommand(sources ?? [], options);
6993
7195
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@larkup/cli",
3
- "version": "0.2.32",
3
+ "version": "0.3.0",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },
@@ -24,6 +24,7 @@
24
24
  "@ai-sdk/openai-compatible": "^3.0.43",
25
25
  "@clack/prompts": "^1.7.0",
26
26
  "@lancedb/lancedb": "^0.38.0",
27
+ "@napi-rs/canvas": "1.0.8",
27
28
  "@pinecone-database/pinecone": "^8.1.0",
28
29
  "ai": "^7.0.91",
29
30
  "apache-arrow": "18.1.0",
@@ -31,10 +32,12 @@
31
32
  "chromadb": "^3.5.0",
32
33
  "cli-table3": "^0.6.5",
33
34
  "commander": "^15.0.0",
35
+ "mammoth": "^1.12.0",
36
+ "pdf-parse": "2.4.5",
34
37
  "zod": "^4.4.3",
35
- "@larkup/marketplace": "0.2.4",
36
38
  "@larkup/core": "0.5.4",
37
- "@larkup/vector-stores": "0.1.27"
39
+ "@larkup/vector-stores": "0.1.27",
40
+ "@larkup/marketplace": "0.2.4"
38
41
  },
39
42
  "devDependencies": {
40
43
  "tsup": "^8.5.1",