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.
- package/README.md +47 -0
- package/dist/index.js +101 -22
- 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(
|
|
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(
|
|
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,
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
1199
|
-
await storeApiKey(
|
|
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
|
-
|
|
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
|
-
|
|
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("");
|