contextloop-cli 0.2.0 → 0.2.2

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 +47 -0
  2. package/dist/index.js +101 -22
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -5,8 +5,10 @@ The official command-line interface for [ContextLoop](https://contextloop.io) -
5
5
  ## Features
6
6
 
7
7
  - **Token-efficient uploads**: Read files directly from disk without generating tokens
8
+ - **Bidirectional sync**: Push, pull, or sync local directories with ContextLoop
8
9
  - **Pipe support**: Stream content from stdin for AI agent integration
9
10
  - **Batch operations**: Upload multiple files with glob patterns
11
+ - **Conflict resolution**: Auto-resolve sync conflicts (newest, local, remote, skip)
10
12
  - **Cross-platform**: Works on macOS, Windows, and Linux
11
13
  - **Secure**: Credentials stored in OS keychain
12
14
  - **Local development**: Easy localhost support for testing
@@ -109,6 +111,46 @@ contextloop comment add <document-id> --body "text" # Add comment
109
111
  contextloop comment resolve <comment-id> # Resolve comment
110
112
  ```
111
113
 
114
+ ### Sync
115
+
116
+ Bidirectional sync between local directories and ContextLoop.
117
+
118
+ ```bash
119
+ contextloop sync [local-dir] # Bidirectional sync (default: current dir)
120
+ contextloop sync push <local-dir> # Push local files to remote
121
+ contextloop sync pull <local-dir> # Pull remote files to local
122
+ ```
123
+
124
+ **Options:**
125
+ ```
126
+ --remote-path <path> Remote path prefix to sync
127
+ --delete Delete files not present on source side
128
+ --conflict <strategy> Conflict resolution: newest, local, remote, skip, error (default: error)
129
+ --concurrency <n> Max concurrent operations (default: 5, max: 10)
130
+ --dry-run Preview changes without executing
131
+ --include <glob> Include pattern (can be repeated)
132
+ --exclude <glob> Exclude pattern (can be repeated)
133
+ --force Skip confirmation for deletions
134
+ ```
135
+
136
+ **Examples:**
137
+ ```bash
138
+ # Preview what would be synced (dry-run)
139
+ contextloop sync ./docs --dry-run
140
+
141
+ # Push local docs to remote with a path prefix
142
+ contextloop sync push ./docs --remote-path /specs
143
+
144
+ # Pull remote files, keeping only .md files
145
+ contextloop sync pull ./docs --include "*.md"
146
+
147
+ # Bidirectional sync, auto-resolve conflicts by newest
148
+ contextloop sync ./docs --conflict newest
149
+
150
+ # Push and delete remote files not present locally
151
+ contextloop sync push ./docs --delete --force
152
+ ```
153
+
112
154
  ## Global Options
113
155
 
114
156
  ```
@@ -177,6 +219,11 @@ echo "# New Doc" | contextloop document upload - --title "Quick Note"
177
219
 
178
220
  # Batch upload
179
221
  contextloop document upload "./docs/**/*.md" --dry-run
222
+
223
+ # Sync entire directory (ideal for keeping docs in sync)
224
+ contextloop sync ./docs --dry-run # Preview changes first
225
+ contextloop sync push ./docs # Push local changes
226
+ contextloop sync pull ./docs # Pull remote changes
180
227
  ```
181
228
 
182
229
  ## Exit Codes
package/dist/index.js CHANGED
@@ -135,6 +135,7 @@ import * as os from "os";
135
135
  var SERVICE_NAME = "contextloop-cli";
136
136
  var CREDENTIALS_FILENAME = "credentials.enc";
137
137
  var LOCAL_CONFIG_DIR = ".contextloop";
138
+ var ENV_FILE_NAME = ".env";
138
139
  var KEY_API_KEY = "api-key";
139
140
  var KEY_ACCESS_TOKEN = "access-token";
140
141
  var KEY_REFRESH_TOKEN = "refresh-token";
@@ -168,6 +169,54 @@ function getOrCreateLocalConfigDir() {
168
169
  }
169
170
  return configDir;
170
171
  }
172
+ function findEnvFile() {
173
+ let current = process.cwd();
174
+ const root = path.parse(current).root;
175
+ while (current !== root) {
176
+ const envFile = path.join(current, ENV_FILE_NAME);
177
+ if (fs.existsSync(envFile) && fs.statSync(envFile).isFile()) {
178
+ return envFile;
179
+ }
180
+ current = path.dirname(current);
181
+ }
182
+ return null;
183
+ }
184
+ function parseEnvFile(filePath) {
185
+ const result = {};
186
+ try {
187
+ const content = fs.readFileSync(filePath, "utf-8");
188
+ const lines = content.split("\n");
189
+ for (const line of lines) {
190
+ const trimmed = line.trim();
191
+ if (!trimmed || trimmed.startsWith("#")) {
192
+ continue;
193
+ }
194
+ const eqIndex = trimmed.indexOf("=");
195
+ if (eqIndex === -1) {
196
+ continue;
197
+ }
198
+ const key = trimmed.slice(0, eqIndex).trim();
199
+ let value = trimmed.slice(eqIndex + 1).trim();
200
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
201
+ value = value.slice(1, -1);
202
+ }
203
+ if (trimmed.slice(eqIndex + 1).trim().startsWith('"')) {
204
+ value = value.replace(/\\n/g, "\n").replace(/\\t/g, " ").replace(/\\\\/g, "\\");
205
+ }
206
+ result[key] = value;
207
+ }
208
+ } catch {
209
+ }
210
+ return result;
211
+ }
212
+ function getApiKeyFromEnvFile() {
213
+ const envFile = findEnvFile();
214
+ if (!envFile) {
215
+ return null;
216
+ }
217
+ const env = parseEnvFile(envFile);
218
+ return env.CONTEXTLOOP_API_KEY ?? null;
219
+ }
171
220
  function getMachineKey() {
172
221
  const machineId = `${os.hostname()}-${os.userInfo().username}-${SERVICE_NAME}`;
173
222
  return crypto2.scryptSync(machineId, "contextloop-cli-salt", KEY_LENGTH);
@@ -328,6 +377,10 @@ async function getApiKeyWithScope() {
328
377
  if (process.env.CONTEXTLOOP_API_KEY) {
329
378
  return { apiKey: process.env.CONTEXTLOOP_API_KEY, scope: "environment" };
330
379
  }
380
+ const envApiKey = getApiKeyFromEnvFile();
381
+ if (envApiKey) {
382
+ return { apiKey: envApiKey, scope: "dotenv" };
383
+ }
331
384
  const localDir = findLocalConfigDir();
332
385
  if (localDir) {
333
386
  try {
@@ -414,6 +467,10 @@ async function getCredentialsWithScope() {
414
467
  scope
415
468
  };
416
469
  }
470
+ async function getCredentials() {
471
+ const { credentials } = await getCredentialsWithScope();
472
+ return credentials;
473
+ }
417
474
  async function clearCredentials(scope = "all") {
418
475
  if (scope === "local" || scope === "all") {
419
476
  const localDir = findLocalConfigDir();
@@ -927,7 +984,7 @@ async function generatePKCE() {
927
984
  const challenge = btoa(String.fromCharCode(...hashArray)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
928
985
  return { verifier, challenge };
929
986
  }
930
- async function interactiveLogin(config2, logger) {
987
+ async function interactiveLogin(config2, logger, local = false) {
931
988
  const state = generateState();
932
989
  const { verifier, challenge } = await generatePKCE();
933
990
  const redirectUri = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
@@ -981,10 +1038,11 @@ ${authUrl.toString()}`);
981
1038
  );
982
1039
  }
983
1040
  const tokens = await response.json();
984
- await storeTokens(tokens.access_token, tokens.refresh_token);
1041
+ await storeTokens(tokens.access_token, tokens.refresh_token, local);
1042
+ const scopeLabel = local ? "project" : "global";
985
1043
  if (tokens.project_id) {
986
1044
  saveConfig({ defaultProjectId: tokens.project_id });
987
- console.log(chalk2.green("Successfully authenticated!"));
1045
+ console.log(chalk2.green(`Successfully authenticated! [${chalk2.cyan(scopeLabel)}]`));
988
1046
  const projectInfo = await fetchProjectInfo(
989
1047
  config2.apiUrl,
990
1048
  tokens.project_id,
@@ -998,10 +1056,14 @@ ${authUrl.toString()}`);
998
1056
  console.log(chalk2.gray(`Default project set to: ${tokens.project_id}`));
999
1057
  }
1000
1058
  } else {
1001
- console.log(chalk2.green("Successfully authenticated!"));
1059
+ console.log(chalk2.green(`Successfully authenticated! [${chalk2.cyan(scopeLabel)}]`));
1060
+ }
1061
+ if (local) {
1062
+ console.log(chalk2.gray("Credentials stored in .contextloop/ directory"));
1063
+ console.log(chalk2.yellow("Tip: Add .contextloop/ to your .gitignore"));
1002
1064
  }
1003
1065
  }
1004
- async function manualLogin(config2, logger) {
1066
+ async function manualLogin(config2, _logger, local = false) {
1005
1067
  const state = generateState();
1006
1068
  const { verifier, challenge } = await generatePKCE();
1007
1069
  const redirectUri = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
@@ -1075,10 +1137,11 @@ async function manualLogin(config2, logger) {
1075
1137
  );
1076
1138
  }
1077
1139
  const tokens = await response.json();
1078
- await storeTokens(tokens.access_token, tokens.refresh_token);
1140
+ await storeTokens(tokens.access_token, tokens.refresh_token, local);
1141
+ const scopeLabel = local ? "project" : "global";
1079
1142
  if (tokens.project_id) {
1080
1143
  saveConfig({ defaultProjectId: tokens.project_id });
1081
- console.log(chalk2.green("Successfully authenticated!"));
1144
+ console.log(chalk2.green(`Successfully authenticated! [${chalk2.cyan(scopeLabel)}]`));
1082
1145
  const projectInfo = await fetchProjectInfo(
1083
1146
  config2.apiUrl,
1084
1147
  tokens.project_id,
@@ -1092,7 +1155,11 @@ async function manualLogin(config2, logger) {
1092
1155
  console.log(chalk2.gray(`Default project set to: ${tokens.project_id}`));
1093
1156
  }
1094
1157
  } else {
1095
- console.log(chalk2.green("Successfully authenticated!"));
1158
+ console.log(chalk2.green(`Successfully authenticated! [${chalk2.cyan(scopeLabel)}]`));
1159
+ }
1160
+ if (local) {
1161
+ console.log(chalk2.gray("Credentials stored in .contextloop/ directory"));
1162
+ console.log(chalk2.yellow("Tip: Add .contextloop/ to your .gitignore"));
1096
1163
  }
1097
1164
  }
1098
1165
 
@@ -1192,14 +1259,29 @@ function registerAuthCommands(program2, context2) {
1192
1259
  try {
1193
1260
  const isLocal = options.local ?? false;
1194
1261
  const scopeLabel = isLocal ? "project" : "global";
1195
- if (options.apiKey) {
1262
+ let apiKey = options.apiKey;
1263
+ let envFilePath = null;
1264
+ if (!apiKey && !options.manual) {
1265
+ const envFile = findEnvFile();
1266
+ if (envFile) {
1267
+ const envApiKey = getApiKeyFromEnvFile();
1268
+ if (envApiKey) {
1269
+ apiKey = envApiKey;
1270
+ envFilePath = envFile;
1271
+ }
1272
+ }
1273
+ }
1274
+ if (apiKey) {
1196
1275
  const spinner = ora("Validating API key...").start();
1197
1276
  try {
1198
- const { user } = await context2.apiClient.validateApiKey(options.apiKey);
1199
- await storeApiKey(options.apiKey, isLocal);
1277
+ const { user } = await context2.apiClient.validateApiKey(apiKey);
1278
+ await storeApiKey(apiKey, isLocal);
1200
1279
  spinner.succeed(
1201
1280
  `Logged in as ${chalk4.bold(user.name)} (${user.email}) [${chalk4.cyan(scopeLabel)}]`
1202
1281
  );
1282
+ if (envFilePath) {
1283
+ console.log(chalk4.gray(`API key loaded from ${envFilePath}`));
1284
+ }
1203
1285
  if (isLocal) {
1204
1286
  console.log(chalk4.gray("Credentials stored in .contextloop/ directory"));
1205
1287
  console.log(chalk4.yellow("Tip: Add .contextloop/ to your .gitignore"));
@@ -1209,15 +1291,9 @@ function registerAuthCommands(program2, context2) {
1209
1291
  throw error;
1210
1292
  }
1211
1293
  } else if (options.manual) {
1212
- if (isLocal) {
1213
- console.log(chalk4.yellow("Note: OAuth login currently only supports global scope"));
1214
- }
1215
- await manualLogin(context2.config, context2.logger);
1294
+ await manualLogin(context2.config, context2.logger, isLocal);
1216
1295
  } else {
1217
- if (isLocal) {
1218
- console.log(chalk4.yellow("Note: OAuth login currently only supports global scope"));
1219
- }
1220
- await interactiveLogin(context2.config, context2.logger);
1296
+ await interactiveLogin(context2.config, context2.logger, isLocal);
1221
1297
  }
1222
1298
  } catch (error) {
1223
1299
  console.error(formatError(error, globalOptions.format));
@@ -1274,20 +1350,23 @@ function registerAuthCommands(program2, context2) {
1274
1350
  }
1275
1351
  spinner.stop();
1276
1352
  const localDir = findLocalConfigDir();
1353
+ const envFile = findEnvFile();
1277
1354
  printOutput(
1278
- { user, project, scope, localDir },
1355
+ { user, project, scope, localDir, envFile },
1279
1356
  {
1280
- text: ({ user: u, project: p, scope: s, localDir: ld }) => {
1357
+ text: ({ user: u, project: p, scope: s, localDir: ld, envFile: ef }) => {
1281
1358
  const lines = [
1282
1359
  chalk4.bold(u.name),
1283
1360
  chalk4.gray(u.email),
1284
1361
  chalk4.gray(`ID: ${u.id}`)
1285
1362
  ];
1286
1363
  lines.push("");
1287
- const scopeLabel = s === "environment" ? chalk4.magenta("environment variable") : s === "local" ? chalk4.cyan("project-local") : chalk4.blue("global");
1364
+ const scopeLabel = s === "environment" ? chalk4.magenta("environment variable") : s === "dotenv" ? chalk4.magenta(".env file") : s === "local" ? chalk4.cyan("project-local") : chalk4.blue("global");
1288
1365
  lines.push(`Auth: ${scopeLabel}`);
1289
1366
  if (s === "local" && ld) {
1290
1367
  lines.push(chalk4.gray(` ${ld}`));
1368
+ } else if (s === "dotenv" && ef) {
1369
+ lines.push(chalk4.gray(` ${ef}`));
1291
1370
  }
1292
1371
  if (p) {
1293
1372
  lines.push("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contextloop-cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "CLI for ContextLoop - upload, download, and manage documents",
5
5
  "type": "module",
6
6
  "bin": {