@tyvm/knowhow 0.0.108-dev.4a8ba55 → 0.0.108-dev.501f36f

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 (56) hide show
  1. package/package.json +1 -1
  2. package/src/chat/CliChatService.ts +3 -0
  3. package/src/cli.ts +14 -0
  4. package/src/clients/index.ts +6 -5
  5. package/src/cloudWorker.ts +110 -122
  6. package/src/commands/misc.ts +5 -0
  7. package/src/commands/services.ts +5 -0
  8. package/src/commands/workers.ts +9 -1
  9. package/src/fileSync.ts +50 -17
  10. package/src/logger.ts +197 -0
  11. package/src/services/EventService.ts +61 -1
  12. package/src/services/KnowhowClient.ts +12 -2
  13. package/src/services/S3.ts +0 -10
  14. package/src/services/modules/index.ts +17 -6
  15. package/src/services/modules/types.ts +2 -0
  16. package/tests/unit/commands/github-credentials.test.ts +211 -0
  17. package/tests/unit/modules/moduleLoading.test.ts +39 -12
  18. package/ts_build/package.json +1 -1
  19. package/ts_build/src/chat/CliChatService.js +3 -0
  20. package/ts_build/src/chat/CliChatService.js.map +1 -1
  21. package/ts_build/src/cli.js +7 -0
  22. package/ts_build/src/cli.js.map +1 -1
  23. package/ts_build/src/clients/index.js +2 -4
  24. package/ts_build/src/clients/index.js.map +1 -1
  25. package/ts_build/src/cloudWorker.d.ts +5 -0
  26. package/ts_build/src/cloudWorker.js +69 -66
  27. package/ts_build/src/cloudWorker.js.map +1 -1
  28. package/ts_build/src/commands/misc.js +2 -0
  29. package/ts_build/src/commands/misc.js.map +1 -1
  30. package/ts_build/src/commands/services.js +2 -1
  31. package/ts_build/src/commands/services.js.map +1 -1
  32. package/ts_build/src/commands/workers.js +6 -1
  33. package/ts_build/src/commands/workers.js.map +1 -1
  34. package/ts_build/src/fileSync.d.ts +6 -0
  35. package/ts_build/src/fileSync.js +37 -12
  36. package/ts_build/src/fileSync.js.map +1 -1
  37. package/ts_build/src/logger.d.ts +21 -0
  38. package/ts_build/src/logger.js +106 -0
  39. package/ts_build/src/logger.js.map +1 -0
  40. package/ts_build/src/services/EventService.d.ts +6 -1
  41. package/ts_build/src/services/EventService.js +29 -0
  42. package/ts_build/src/services/EventService.js.map +1 -1
  43. package/ts_build/src/services/KnowhowClient.d.ts +1 -1
  44. package/ts_build/src/services/KnowhowClient.js +8 -2
  45. package/ts_build/src/services/KnowhowClient.js.map +1 -1
  46. package/ts_build/src/services/S3.js +0 -7
  47. package/ts_build/src/services/S3.js.map +1 -1
  48. package/ts_build/src/services/modules/index.d.ts +1 -1
  49. package/ts_build/src/services/modules/index.js +10 -5
  50. package/ts_build/src/services/modules/index.js.map +1 -1
  51. package/ts_build/src/services/modules/types.d.ts +2 -0
  52. package/ts_build/tests/unit/commands/github-credentials.test.d.ts +1 -0
  53. package/ts_build/tests/unit/commands/github-credentials.test.js +146 -0
  54. package/ts_build/tests/unit/commands/github-credentials.test.js.map +1 -0
  55. package/ts_build/tests/unit/modules/moduleLoading.test.js +20 -7
  56. package/ts_build/tests/unit/modules/moduleLoading.test.js.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyvm/knowhow",
3
- "version": "0.0.108-dev.4a8ba55",
3
+ "version": "0.0.108-dev.501f36f",
4
4
  "description": "ai cli with plugins and agents",
5
5
  "main": "ts_build/src/index.js",
6
6
  "bin": {
@@ -19,6 +19,7 @@ import editor from "@inquirer/editor";
19
19
  import fs from "fs";
20
20
  import path from "path";
21
21
  import { services } from "../services";
22
+ import { logger } from "../logger";
22
23
 
23
24
  export class CliChatService implements ChatService {
24
25
  private context: ChatContext;
@@ -267,10 +268,12 @@ export class CliChatService implements ChatService {
267
268
  } else if (this.context.multilineMode) {
268
269
  const renderer = this.context.renderer;
269
270
  if (renderer) renderer.pause();
271
+ logger.silence();
270
272
  try {
271
273
  value = await editor({ message: prompt });
272
274
  } finally {
273
275
  if (renderer) renderer.resume();
276
+ logger.unsilence();
274
277
  }
275
278
  this.context.multilineMode = false; // Disable after use like original
276
279
  } else {
package/src/cli.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node --no-node-snapshot
2
2
  import { Command } from "commander";
3
3
  import { version } from "../package.json";
4
+ import { logger } from "./logger";
4
5
  import { migrateConfig } from "./config";
5
6
  import { getConfig, getGlobalConfig } from "./config";
6
7
  import { getEnabledPlugins } from "./types";
@@ -48,6 +49,19 @@ process.on("unhandledRejection", (reason: unknown) => {
48
49
 
49
50
  async function main() {
50
51
  const program = new Command();
52
+
53
+ // Install console overload early so ALL output (including third-party modules)
54
+ // goes through our logger closure — respects silence() for clean-stdout commands.
55
+ logger.installConsoleOverload();
56
+
57
+ // Silence immediately if this is a clean-stdout command (e.g. git credential helpers).
58
+ // Module loading happens before parseAsync, so we must silence before that point.
59
+ const rawArgs = process.argv.slice(2);
60
+ const SILENT_COMMANDS = ["github-credentials"];
61
+ if (rawArgs.some((a) => SILENT_COMMANDS.includes(a))) {
62
+ logger.silence();
63
+ }
64
+
51
65
  await migrateConfig();
52
66
  const config = await getConfig();
53
67
  const chatService = new CliChatService(getEnabledPlugins(config.plugins));
@@ -577,11 +577,12 @@ export class AIClient {
577
577
  * @param modelQuery - the model name to search for (can be partial/normalized)
578
578
  * @param provider - optional provider to restrict search to
579
579
  */
580
- findModelFuzzy(modelQuery: string, provider?: string): { provider: string; model: string } | undefined {
580
+ findModelFuzzy(
581
+ modelQuery: string,
582
+ provider?: string
583
+ ): { provider: string; model: string } | undefined {
581
584
  const queryNorm = AIClient.normalizeModelId(modelQuery);
582
- const providers = provider
583
- ? [provider]
584
- : Object.keys(this.clientModels);
585
+ const providers = provider ? [provider] : Object.keys(this.clientModels);
585
586
 
586
587
  for (const p of providers) {
587
588
  const models = (this.clientModels[p] as string[]) ?? [];
@@ -835,7 +836,7 @@ export class AIClient {
835
836
  const splitModel = m.id.split("/");
836
837
 
837
838
  if (splitModel.length < 2) {
838
- console.error(`Cannot parse model format: ${m.id}`);
839
+ console.warn(`Cannot parse model format: ${m.id}`);
839
840
  }
840
841
 
841
842
  const provider = splitModel.length > 1 ? splitModel[0] : "";
@@ -5,7 +5,7 @@ import { loadJwt } from "./login";
5
5
  import { getConfig, updateConfig, getLanguageConfig } from "./config";
6
6
  import { services } from "./services";
7
7
  import { Language, Config, McpConfig } from "./types";
8
- import { S3Service } from "./services/S3";
8
+ import { uploadFile, uploadDirectory } from "./fileSync";
9
9
 
10
10
  export interface CloudWorkerPullOptions {
11
11
  id: string;
@@ -15,6 +15,7 @@ export interface CloudWorkerPullOptions {
15
15
  export interface CloudWorkerOptions {
16
16
  create?: boolean;
17
17
  push?: string; // uid of existing cloud worker
18
+ init?: boolean; // initialize config.files entries (mutates config)
18
19
  name?: string; // optional name for create
19
20
  apiUrl?: string;
20
21
  dryRun?: boolean;
@@ -30,25 +31,6 @@ interface FileToSync {
30
31
  isDirectory?: boolean; // true if this represents a whole directory
31
32
  }
32
33
 
33
- /**
34
- * Recursively list all files in a local directory, returning relative paths
35
- */
36
- function listFilesRecursively(dir: string): string[] {
37
- const results: string[] = [];
38
- if (!fs.existsSync(dir)) return results;
39
- const entries = fs.readdirSync(dir, { withFileTypes: true });
40
- for (const entry of entries) {
41
- if (entry.isDirectory()) {
42
- listFilesRecursively(path.join(dir, entry.name)).forEach((f) =>
43
- results.push(entry.name + "/" + f)
44
- );
45
- } else {
46
- results.push(entry.name);
47
- }
48
- }
49
- return results;
50
- }
51
-
52
34
  /**
53
35
  * Build the worker config JSON from the local knowhow config
54
36
  */
@@ -71,21 +53,19 @@ function buildWorkerConfigJson(config: Config, files: { remotePath: string; loca
71
53
  }
72
54
 
73
55
  /**
74
- * Collect all files from the .knowhow directory that should be synced
75
- * Uses directory-level entries where possible so the worker config stays compact
76
- * and the folder upload/download feature handles individual files automatically.
56
+ * Collect all files from the .knowhow directory that should be synced.
57
+ * Only includes files/directories that currently exist locally.
58
+ * Used by --init to populate config.files.
77
59
  */
78
60
  async function collectFilesToSync(projectName: string): Promise<FileToSync[]> {
79
61
  const filesToSync: FileToSync[] = [];
80
62
 
81
- // Helper to add file if it exists
82
63
  const addIfExists = (localPath: string, remotePath: string) => {
83
64
  if (fs.existsSync(localPath)) {
84
65
  filesToSync.push({ localPath, remotePath });
85
66
  }
86
67
  };
87
68
 
88
- // Helper to add a directory entry if it exists (trailing slash = directory mode)
89
69
  const addDirIfExists = (localPath: string, remotePath: string) => {
90
70
  if (fs.existsSync(localPath)) {
91
71
  filesToSync.push({ localPath: localPath + "/", remotePath: remotePath + "/", isDirectory: true });
@@ -108,7 +88,9 @@ async function collectFilesToSync(projectName: string): Promise<FileToSync[]> {
108
88
  }
109
89
 
110
90
  /**
111
- * Collect files referenced in language.json sources
91
+ * Collect files referenced in language.json sources.
92
+ * These are always re-collected on both --init and --push so that new
93
+ * language term sources are picked up automatically.
112
94
  */
113
95
  async function collectLanguageReferencedFiles(
114
96
  language: Language,
@@ -124,17 +106,14 @@ async function collectLanguageReferencedFiles(
124
106
  if (source.kind !== "file" || !source.data) continue;
125
107
 
126
108
  for (const filePath of source.data) {
127
- // Normalize the path (strip leading ./)
128
109
  const normalizedPath = filePath.replace(/^\.\//, "");
129
110
 
130
111
  // Skip the main knowhow config — it should not be synced to the language folder
131
- // as it would overwrite the worker's own config
132
112
  if (normalizedPath === ".knowhow/knowhow.json") continue;
133
113
 
134
114
  if (fs.existsSync(normalizedPath)) {
135
115
  const basename = path.basename(normalizedPath);
136
116
  const remotePath = `${projectName}/.knowhow/language/${basename}`;
137
- // localPath is the original path so the worker downloads it to the right place
138
117
  filesToSync.push({ localPath: normalizedPath, remotePath, downloadLocalPath: normalizedPath });
139
118
  }
140
119
  }
@@ -145,37 +124,83 @@ async function collectLanguageReferencedFiles(
145
124
  }
146
125
 
147
126
  /**
148
- * Upload a single file to the cloud worker's file storage
127
+ * Collect language-referenced files if language.json is present in the
128
+ * given config.files entries. Returns empty array if language.json is not
129
+ * configured for sync.
130
+ */
131
+ async function collectLanguageFilesIfConfigured(
132
+ configFiles: { remotePath: string; localPath: string }[],
133
+ projectName: string
134
+ ): Promise<FileToSync[]> {
135
+ const syncingLanguage = configFiles.some(
136
+ (f) => !f.remotePath.endsWith("/") && f.remotePath.endsWith("language.json")
137
+ );
138
+ if (!syncingLanguage) return [];
139
+
140
+ const language = await getLanguageConfig();
141
+ return collectLanguageReferencedFiles(language, projectName);
142
+ }
143
+
144
+ /**
145
+ * Initialize the local config.files entries based on what exists in .knowhow/.
146
+ * This is the --init step — mutates config. Run once to set up sync entries.
147
+ * language-referenced files are also collected if language.json is present.
149
148
  */
150
- async function uploadSingleFile(
151
- client: KnowhowSimpleClient,
152
- s3Service: S3Service,
153
- localPath: string,
154
- remotePath: string,
155
- dryRun: boolean
156
- ): Promise<void> {
157
- console.log(` ⬆️ Uploading ${localPath} → ${remotePath}`);
158
-
159
- if (dryRun) {
160
- console.log(` [DRY RUN] Would upload from ${localPath}`);
161
- return;
149
+ export async function initCloudWorker(options: { apiUrl?: string; dryRun?: boolean } = {}) {
150
+ const { dryRun = false } = options;
151
+
152
+ const config = await getConfig();
153
+ if (!config || Object.keys(config).length === 0) {
154
+ console.error("❌ No knowhow config found. Please run 'knowhow init' first.");
155
+ process.exit(1);
162
156
  }
163
157
 
164
- if (!fs.existsSync(localPath)) {
165
- console.warn(` ⚠️ Local file not found, skipping: ${localPath}`);
166
- return;
158
+ const projectName = path.basename(process.cwd());
159
+ console.log(`📁 Project name: ${projectName}`);
160
+
161
+ console.log("\n📂 Collecting files to sync...");
162
+ const mainFiles = await collectFilesToSync(projectName);
163
+ const languageFiles = await collectLanguageFilesIfConfigured(mainFiles, projectName);
164
+
165
+ if (languageFiles.length === 0 && !mainFiles.some((f) => f.remotePath.endsWith("language.json"))) {
166
+ console.log(" ℹ️ Skipping language-referenced files (language.json not found locally)");
167
167
  }
168
168
 
169
- const presignedUrl = await client.getOrgFilePresignedUploadUrl(remotePath);
170
- await s3Service.uploadToPresignedUrl(presignedUrl, localPath);
171
- await client.markOrgFileUploadComplete(remotePath);
169
+ // Deduplicate by remotePath
170
+ const allFilesMap = new Map<string, FileToSync>();
171
+ for (const f of [...mainFiles, ...languageFiles]) {
172
+ allFilesMap.set(f.remotePath, f);
173
+ }
174
+ const allFiles = Array.from(allFilesMap.values());
175
+
176
+ console.log(` Found ${allFiles.length} files to register`);
172
177
 
173
- const stats = fs.statSync(localPath);
174
- console.log(` ✓ Uploaded ${stats.size} bytes`);
178
+ const configFilesEntries = allFiles.map((f) => ({
179
+ remotePath: f.remotePath,
180
+ localPath: f.downloadLocalPath ?? f.localPath,
181
+ direction: "download" as const,
182
+ }));
183
+
184
+ console.log("\n💾 Updating config.files with sync entries...");
185
+ if (!dryRun) {
186
+ const existingFiles = config.files || [];
187
+ const newRemotePaths = new Set(configFilesEntries.map((e) => e.remotePath));
188
+ const preserved = existingFiles.filter((e) => !newRemotePaths.has(e.remotePath));
189
+ config.files = [...preserved, ...configFilesEntries];
190
+ await updateConfig(config);
191
+ console.log(` ✓ Updated config with ${config.files.length} file entries`);
192
+ } else {
193
+ console.log(` [DRY RUN] Would update config with ${configFilesEntries.length} file entries`);
194
+ for (const f of allFiles) {
195
+ console.log(` ${f.localPath} → ${f.remotePath}`);
196
+ }
197
+ }
175
198
  }
176
199
 
177
200
  /**
178
- * Main cloudWorker command handler
201
+ * Main cloudWorker command handler — push/create only.
202
+ * Reads config.files (set up by --init) and also re-collects any language-referenced
203
+ * files so new language term sources are always included without requiring --init again.
179
204
  */
180
205
  export async function cloudWorker(options: CloudWorkerOptions) {
181
206
  const {
@@ -205,10 +230,6 @@ export async function cloudWorker(options: CloudWorkerOptions) {
205
230
  process.exit(1);
206
231
  }
207
232
 
208
- // Load language config
209
- const language = await getLanguageConfig();
210
-
211
- // Get project name from current directory
212
233
  const projectName = path.basename(process.cwd());
213
234
  console.log(`📁 Project name: ${projectName}`);
214
235
 
@@ -218,86 +239,63 @@ export async function cloudWorker(options: CloudWorkerOptions) {
218
239
  // Get S3 service
219
240
  const { AwsS3 } = services();
220
241
 
221
- // Step 1: Collect all files to sync
222
- console.log("\n📂 Collecting files to sync...");
223
- const mainFiles = await collectFilesToSync(projectName);
224
- const languageFiles = await collectLanguageReferencedFiles(language, projectName);
225
-
226
- // Deduplicate by remotePath
227
- const allFilesMap = new Map<string, FileToSync>();
228
- for (const f of [...mainFiles, ...languageFiles]) {
229
- allFilesMap.set(f.remotePath, f);
242
+ // Start with config.files (set up via --init)
243
+ const configFiles = config.files || [];
244
+ if (configFiles.length === 0) {
245
+ console.warn("⚠️ No files configured. Run 'knowhow cloudworker --init' first to set up file sync entries.");
230
246
  }
231
- const allFiles = Array.from(allFilesMap.values());
232
-
233
- console.log(` Found ${allFiles.length} files to sync`);
234
247
 
235
- if (dryRun) {
236
- console.log("\n📋 Files that would be synced:");
237
- for (const f of allFiles) {
238
- console.log(` ${f.localPath} ${f.remotePath}`);
239
- }
248
+ // Re-collect language-referenced files on every push (if language.json is in config.files)
249
+ // so that new language term sources are picked up without needing --init again.
250
+ const languageFiles = await collectLanguageFilesIfConfigured(configFiles, projectName);
251
+ if (languageFiles.length > 0) {
252
+ console.log(` + ${languageFiles.length} language-referenced file(s) to sync`);
240
253
  }
241
254
 
242
- // Step 2: Build the config.files array for all synced files
243
- const configFilesEntries = allFiles.map((f) => ({
244
- remotePath: f.remotePath,
245
- localPath: f.downloadLocalPath ?? f.localPath,
246
- direction: "download" as const,
247
- }));
248
-
249
- // Step 3: Update config.files and save
250
- console.log("\n💾 Updating config.files with sync entries...");
251
- if (!dryRun) {
252
- // Preserve any existing files entries not in our set
253
- const existingFiles = config.files || [];
254
- const newRemotePaths = new Set(configFilesEntries.map((e) => e.remotePath));
255
-
256
- // Keep entries that don't overlap with new ones
257
- const preserved = existingFiles.filter(
258
- (e) => !newRemotePaths.has(e.remotePath)
259
- );
255
+ // Merge language files into the upload list (deduplicate by remotePath)
256
+ const allFilesMap = new Map<string, { remotePath: string; localPath: string }>();
257
+ for (const f of configFiles) {
258
+ allFilesMap.set(f.remotePath, f);
259
+ }
260
+ for (const f of languageFiles) {
261
+ const entry = { remotePath: f.remotePath, localPath: f.downloadLocalPath ?? f.localPath };
262
+ allFilesMap.set(f.remotePath, entry);
263
+ }
264
+ const allFiles = Array.from(allFilesMap.values());
260
265
 
261
- config.files = [...preserved, ...configFilesEntries];
266
+ // If new language files were found, update config.files so they persist
267
+ if (languageFiles.length > 0 && !dryRun) {
268
+ config.files = allFiles.map((f) => ({ ...f, direction: "download" as const }));
262
269
  await updateConfig(config);
263
- console.log(` ✓ Updated config with ${config.files.length} file entries`);
264
- } else {
265
- console.log(` [DRY RUN] Would update config with ${configFilesEntries.length} file entries`);
266
270
  }
267
271
 
268
- // Step 4: Build workerConfigJson
269
- const workerConfigJson = buildWorkerConfigJson(config, configFilesEntries);
272
+ // Build the workerConfigJson using the full file list
273
+ const workerConfigJson = buildWorkerConfigJson(config, allFiles.map((f) => ({ ...f, direction: "download" })));
270
274
 
271
- // Step 5: Upload all files
272
- console.log(`\n🚀 Uploading ${allFiles.length} files...`);
275
+ // Upload all files
276
+ console.log(`\n🚀 Uploading ${allFiles.length} configured files...`);
273
277
  let successCount = 0;
274
278
  let failCount = 0;
275
279
 
276
- for (const file of allFiles) {
280
+ for (const mount of allFiles) {
281
+ const { remotePath, localPath } = mount;
277
282
  try {
278
- if (file.isDirectory) {
279
- // Upload all files recursively in the local directory
280
- const localDir = file.localPath.endsWith("/") ? file.localPath : file.localPath + "/";
281
- const remoteDir = file.remotePath.endsWith("/") ? file.remotePath : file.remotePath + "/";
282
- const relFiles = listFilesRecursively(localDir);
283
- console.log(` 📁 Uploading directory ${localDir} → ${remoteDir} (${relFiles.length} files)`);
284
- for (const relFile of relFiles) {
285
- await uploadSingleFile(client, AwsS3, localDir + relFile, remoteDir + relFile, dryRun);
286
- successCount++;
287
- }
283
+ if (remotePath.endsWith("/") || localPath.endsWith("/")) {
284
+ const count = await uploadDirectory(client, AwsS3, remotePath, localPath, dryRun);
285
+ successCount += count;
288
286
  } else {
289
- await uploadSingleFile(client, AwsS3, file.localPath, file.remotePath, dryRun);
287
+ await uploadFile(client, AwsS3, remotePath, localPath, dryRun);
290
288
  successCount++;
291
289
  }
292
290
  } catch (error) {
293
- console.error(` ❌ Failed to upload ${file.localPath}: ${error.message}`);
291
+ console.error(` ❌ Failed to upload ${localPath}: ${error.message}`);
294
292
  failCount++;
295
293
  }
296
294
  }
297
295
 
298
296
  console.log(`\n ✓ Upload complete: ${successCount} succeeded, ${failCount} failed`);
299
297
 
300
- // Step 6: Create or update cloud worker
298
+ // Create or update cloud worker
301
299
  if (create) {
302
300
  const workerName = name || `${projectName}-worker`;
303
301
  console.log(`\n🌩️ Creating cloud worker "${workerName}"...`);
@@ -339,16 +337,6 @@ export async function cloudWorker(options: CloudWorkerOptions) {
339
337
  /**
340
338
  * Pull the latest workerConfigJson from the cloud worker API and update the
341
339
  * local knowhow.json config to match.
342
- *
343
- * This is the "pull" half of the config sync cycle. After running this,
344
- * you can reload the worker's MCPs (in-process) via the reloadConfig
345
- * WebSocket message or by calling `knowhow worker` again.
346
- *
347
- * Merged fields from workerConfigJson:
348
- * - mcps → overwrites config.mcps
349
- * - modules → overwrites config.modules (optional, only if present)
350
- * - plugins → overwrites config.plugins (optional, only if present)
351
- * - agents → overwrites config.agents (optional, only if present)
352
340
  */
353
341
  export async function pullCloudWorkerConfig(options: CloudWorkerPullOptions) {
354
342
  const { id, apiUrl = KNOWHOW_API_URL } = options;
@@ -1,6 +1,7 @@
1
1
  import { Command } from "commander";
2
2
  import { execSync } from "child_process";
3
3
  import { version } from "../../package.json";
4
+ import { logger } from "../logger";
4
5
  import { generate, embed, upload, download, purge } from "../index";
5
6
  import { init } from "../config";
6
7
  import { login } from "../login";
@@ -118,6 +119,10 @@ export function addGithubCredentialsCommand(program: Command): void {
118
119
  "Repository in owner/repo format (e.g. myorg/myrepo)"
119
120
  )
120
121
  .action(async (action: string | undefined, options: { repo?: string }) => {
122
+ // Silence ALL output immediately — git credential helpers must produce
123
+ // only the protocol=.../host=.../username=.../password=... lines on stdout.
124
+ logger.silence();
125
+
121
126
  const client = new KnowhowSimpleClient();
122
127
 
123
128
  let repo = options.repo;
@@ -15,8 +15,12 @@ export async function setupServices() {
15
15
  Tools: AllTools,
16
16
  Embeddings,
17
17
  Plugins,
18
+ Events,
18
19
  MediaProcessor,
19
20
  } = services();
21
+
22
+
23
+ // cli uses LazyTools to keep context slim
20
24
  const Tools = new LazyToolsService();
21
25
 
22
26
  Tools.setContext({
@@ -66,6 +70,7 @@ export async function setupServices() {
66
70
  Clients,
67
71
  Tools,
68
72
  MediaProcessor,
73
+ Events
69
74
  });
70
75
 
71
76
  return { Tools, Clients };
@@ -128,6 +128,10 @@ export function addCloudWorkerCommand(program: Command): void {
128
128
  program
129
129
  .command("cloudworker")
130
130
  .description("Create or sync a cloud worker with your local knowhow config")
131
+ .option(
132
+ "--init",
133
+ "Initialize config.files entries based on what exists in .knowhow/ (run once before --push)"
134
+ )
131
135
  .option(
132
136
  "--create",
133
137
  "Create a new cloud worker with synced config and files"
@@ -144,9 +148,13 @@ export function addCloudWorkerCommand(program: Command): void {
144
148
  .option("--dry-run", "Print what would be synced without doing it")
145
149
  .action(async (options) => {
146
150
  try {
147
- const { cloudWorker, pullCloudWorkerConfig } = await import(
151
+ const { cloudWorker, pullCloudWorkerConfig, initCloudWorker } = await import(
148
152
  "../cloudWorker"
149
153
  );
154
+ if (options.init) {
155
+ await initCloudWorker({ dryRun: options.dryRun });
156
+ return;
157
+ }
150
158
  if (options.pull) {
151
159
  await pullCloudWorkerConfig({ id: options.pull });
152
160
  } else {
package/src/fileSync.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
+ import * as os from "os";
3
4
  import { KnowhowSimpleClient, KNOWHOW_API_URL } from "./services/KnowhowClient";
4
5
  import { loadJwt } from "./login";
5
6
  import { getConfig } from "./config";
@@ -7,6 +8,8 @@ import { services } from "./services";
7
8
  import { S3Service } from "./services/S3";
8
9
  import { getHashes, hasFileChangedSinceUpload, saveUploadHash, isLocalFileMatchingRemote, isLocalFileMatchingDownloadHash, saveDownloadHash } from "./hashes";
9
10
 
11
+ export const DEFAULT_BATCH_SIZE = 5;
12
+
10
13
  export interface FileSyncOptions {
11
14
  upload?: boolean;
12
15
  download?: boolean;
@@ -15,6 +18,33 @@ export interface FileSyncOptions {
15
18
  dryRun?: boolean;
16
19
  }
17
20
 
21
+ /**
22
+ * Run an array of async tasks in batches of `batchSize` at a time.
23
+ * Returns results in the same order as the input tasks.
24
+ */
25
+ export async function batchRun<T>(
26
+ tasks: (() => Promise<T>)[],
27
+ batchSize: number = DEFAULT_BATCH_SIZE
28
+ ): Promise<T[]> {
29
+ const results: T[] = [];
30
+ for (let i = 0; i < tasks.length; i += batchSize) {
31
+ const batch = tasks.slice(i, i + batchSize);
32
+ const batchResults = await Promise.all(batch.map((t) => t()));
33
+ results.push(...batchResults);
34
+ }
35
+ return results;
36
+ }
37
+
38
+ /**
39
+ * Expands a leading ~ to the user's home directory
40
+ */
41
+ function expandHome(p: string): string {
42
+ if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) {
43
+ return path.join(os.homedir(), p.slice(1));
44
+ }
45
+ return p;
46
+ }
47
+
18
48
  /**
19
49
  * Returns true if the path looks like a directory (ends with /)
20
50
  */
@@ -25,7 +55,7 @@ function isDirectoryPath(p: string): boolean {
25
55
  /**
26
56
  * Recursively list all files in a local directory, returning relative paths
27
57
  */
28
- function listFilesRecursively(dir: string): string[] {
58
+ export function listFilesRecursively(dir: string): string[] {
29
59
  const results: string[] = [];
30
60
  if (!fs.existsSync(dir)) return results;
31
61
  const entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -83,7 +113,8 @@ export async function fileSync(options: FileSyncOptions = {}) {
83
113
 
84
114
  // Process each file mount
85
115
  for (const mount of config.files) {
86
- const { remotePath, localPath, direction = "download" } = mount;
116
+ const { remotePath, localPath: rawLocalPath, direction = "download" } = mount;
117
+ const localPath = expandHome(rawLocalPath);
87
118
 
88
119
  // Determine actual direction based on flags and config
89
120
  let actualDirection = direction;
@@ -126,11 +157,10 @@ export async function fileSync(options: FileSyncOptions = {}) {
126
157
  }
127
158
  }
128
159
 
129
-
130
160
  /**
131
161
  * Download a file from Knowhow FS to local filesystem
132
162
  */
133
- async function downloadFile(
163
+ export async function downloadFile(
134
164
  client: KnowhowSimpleClient,
135
165
  s3Service: S3Service,
136
166
  remotePath: string,
@@ -186,7 +216,7 @@ async function downloadFile(
186
216
  /**
187
217
  * Upload a file from local filesystem to Knowhow FS
188
218
  */
189
- async function uploadFile(
219
+ export async function uploadFile(
190
220
  client: KnowhowSimpleClient,
191
221
  s3Service: S3Service,
192
222
  remotePath: string,
@@ -215,7 +245,7 @@ async function uploadFile(
215
245
  }
216
246
 
217
247
  // Get presigned upload URL
218
- const presignedUrl = await client.getOrgFilePresignedUploadUrl(remotePath);
248
+ const presignedUrl = await client.getOrgFilePresignedUploadUrl(remotePath, localPath);
219
249
 
220
250
  // Upload file using presigned URL
221
251
  await s3Service.uploadToPresignedUrl(presignedUrl, localPath);
@@ -261,26 +291,28 @@ export async function uploadDirectory(
261
291
 
262
292
  console.log(` Found ${localFiles.length} local file(s)`);
263
293
 
264
- let count = 0;
265
- for (const relFile of localFiles) {
294
+ const tasks = localFiles.map((relFile) => async () => {
266
295
  const localFilePath = localDir + relFile;
267
296
  const remoteFilePath = remoteDir + relFile;
268
297
  try {
269
298
  await uploadFile(client, s3Service, remoteFilePath, localFilePath, dryRun);
270
- count++;
299
+ return 1;
271
300
  } catch (error) {
272
301
  console.error(
273
302
  ` ❌ Failed to upload ${localFilePath}, skipping: ${error.message}`
274
303
  );
304
+ return 0;
275
305
  }
276
- }
277
- return count;
306
+ });
307
+
308
+ const counts = await batchRun(tasks);
309
+ return counts.reduce((sum, n) => sum + n, 0);
278
310
  }
279
311
 
280
312
  /**
281
313
  * Download all files from a remote directory path to a local directory
282
314
  */
283
- async function downloadDirectory(
315
+ export async function downloadDirectory(
284
316
  client: KnowhowSimpleClient,
285
317
  s3Service: S3Service,
286
318
  remotePath: string,
@@ -313,8 +345,7 @@ async function downloadDirectory(
313
345
 
314
346
  console.log(` Found ${matchingFiles.length} remote file(s)`);
315
347
 
316
- let count = 0;
317
- for (const f of matchingFiles) {
348
+ const tasks = matchingFiles.map((f) => async () => {
318
349
  const fullRemotePath = f.folderPath.endsWith("/")
319
350
  ? f.folderPath + f.fileName
320
351
  : f.folderPath + "/" + f.fileName;
@@ -322,7 +353,9 @@ async function downloadDirectory(
322
353
  const relativePath = fullRemotePath.slice(remoteDir.length);
323
354
  const localFilePath = localDir + relativePath;
324
355
  await downloadFile(client, s3Service, fullRemotePath, localFilePath, dryRun);
325
- count++;
326
- }
327
- return count;
356
+ return 1;
357
+ });
358
+
359
+ const counts = await batchRun(tasks);
360
+ return counts.reduce((sum, n) => sum + n, 0);
328
361
  }