@forloop-cc/forloop-cli 0.2.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/ERRORS.md +260 -0
- package/README.md +636 -0
- package/dist/forloop.mjs +2440 -0
- package/install.sh +90 -0
- package/package.json +52 -0
package/dist/forloop.mjs
ADDED
|
@@ -0,0 +1,2440 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/args.ts
|
|
4
|
+
function kebabToCamel(str) {
|
|
5
|
+
return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
6
|
+
}
|
|
7
|
+
function flagKey(def) {
|
|
8
|
+
const m = def.flag.match(/^--([a-z][a-z0-9-]*)/i);
|
|
9
|
+
return m ? kebabToCamel(m[1]) : null;
|
|
10
|
+
}
|
|
11
|
+
function isBooleanDef(def) {
|
|
12
|
+
if (def.type === "boolean") return true;
|
|
13
|
+
if (def.type === "string" || def.type === "number" || def.type === "array") return false;
|
|
14
|
+
return !def.flag.includes("<") && !def.flag.includes("[");
|
|
15
|
+
}
|
|
16
|
+
function buildSchema(options) {
|
|
17
|
+
const booleans = /* @__PURE__ */ new Set();
|
|
18
|
+
const numbers = /* @__PURE__ */ new Set();
|
|
19
|
+
const arrays = /* @__PURE__ */ new Set();
|
|
20
|
+
for (const opt of options) {
|
|
21
|
+
const key = flagKey(opt);
|
|
22
|
+
if (!key) continue;
|
|
23
|
+
if (isBooleanDef(opt)) booleans.add(key);
|
|
24
|
+
else if (opt.type === "number") numbers.add(key);
|
|
25
|
+
else if (opt.type === "array") arrays.add(key);
|
|
26
|
+
}
|
|
27
|
+
return { booleans, numbers, arrays };
|
|
28
|
+
}
|
|
29
|
+
function scanCommandPath(argv, globalOptions = []) {
|
|
30
|
+
const globalSchema = buildSchema(globalOptions);
|
|
31
|
+
const path6 = [];
|
|
32
|
+
let i = 0;
|
|
33
|
+
while (i < argv.length) {
|
|
34
|
+
const arg = argv[i];
|
|
35
|
+
if (arg === "--") break;
|
|
36
|
+
if (arg.startsWith("--")) {
|
|
37
|
+
const eqIdx = arg.indexOf("=");
|
|
38
|
+
const key = eqIdx !== -1 ? arg.slice(2, eqIdx) : arg.slice(2);
|
|
39
|
+
const camelKey = kebabToCamel(key);
|
|
40
|
+
if (!globalSchema.booleans.has(camelKey) && eqIdx === -1) {
|
|
41
|
+
i += 2;
|
|
42
|
+
} else {
|
|
43
|
+
i += 1;
|
|
44
|
+
}
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (arg.startsWith("-")) {
|
|
48
|
+
i++;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
path6.push(arg);
|
|
52
|
+
i++;
|
|
53
|
+
}
|
|
54
|
+
return path6;
|
|
55
|
+
}
|
|
56
|
+
function parseFlags(argv, options) {
|
|
57
|
+
const schema = buildSchema(options);
|
|
58
|
+
const flags = {
|
|
59
|
+
quiet: false,
|
|
60
|
+
verbose: false,
|
|
61
|
+
noColor: false,
|
|
62
|
+
dryRun: false,
|
|
63
|
+
help: false,
|
|
64
|
+
nonInteractive: false
|
|
65
|
+
};
|
|
66
|
+
let i = 0;
|
|
67
|
+
while (i < argv.length) {
|
|
68
|
+
const arg = argv[i];
|
|
69
|
+
if (arg === "--help" || arg === "-h") {
|
|
70
|
+
flags.help = true;
|
|
71
|
+
i++;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (arg === "--") {
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
if (arg.startsWith("--")) {
|
|
78
|
+
const eqIdx = arg.indexOf("=");
|
|
79
|
+
let key;
|
|
80
|
+
let value;
|
|
81
|
+
if (eqIdx !== -1) {
|
|
82
|
+
key = arg.slice(2, eqIdx);
|
|
83
|
+
value = arg.slice(eqIdx + 1);
|
|
84
|
+
} else {
|
|
85
|
+
key = arg.slice(2);
|
|
86
|
+
}
|
|
87
|
+
const camelKey = kebabToCamel(key);
|
|
88
|
+
if (schema.booleans.has(camelKey)) {
|
|
89
|
+
flags[camelKey] = true;
|
|
90
|
+
i++;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (value === void 0) {
|
|
94
|
+
i++;
|
|
95
|
+
value = argv[i];
|
|
96
|
+
}
|
|
97
|
+
if (value === void 0) throw new Error(`Flag --${key} requires a value.`);
|
|
98
|
+
if (schema.arrays.has(camelKey)) {
|
|
99
|
+
const arr = flags[camelKey];
|
|
100
|
+
if (arr) arr.push(value);
|
|
101
|
+
else flags[camelKey] = [value];
|
|
102
|
+
} else if (schema.numbers.has(camelKey)) {
|
|
103
|
+
flags[camelKey] = Number(value);
|
|
104
|
+
} else {
|
|
105
|
+
flags[camelKey] = value;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
i++;
|
|
109
|
+
}
|
|
110
|
+
return flags;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// src/errors/base.ts
|
|
114
|
+
var CLIError = class extends Error {
|
|
115
|
+
constructor(message, exitCode = 1, suggestion) {
|
|
116
|
+
super(message);
|
|
117
|
+
this.exitCode = exitCode;
|
|
118
|
+
this.suggestion = suggestion;
|
|
119
|
+
this.name = "CLIError";
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// src/command.ts
|
|
124
|
+
function defineCommand(spec) {
|
|
125
|
+
return {
|
|
126
|
+
...spec,
|
|
127
|
+
execute: spec.run
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
var GLOBAL_OPTIONS = [
|
|
131
|
+
{ flag: "--api-key <key>", description: "ForLoop API key" },
|
|
132
|
+
{ flag: "--api-url <url>", description: "API base URL (overrides config)" },
|
|
133
|
+
{ flag: "--base-url <url>", description: "Alias for --api-url" },
|
|
134
|
+
{ flag: "--output <format>", description: "Output format: text (default), json" },
|
|
135
|
+
{ flag: "--timeout <seconds>", description: "Request timeout (default: 60)", type: "number" },
|
|
136
|
+
{ flag: "--quiet", description: "Suppress non-essential output" },
|
|
137
|
+
{ flag: "--verbose", description: "Print HTTP request/response details" },
|
|
138
|
+
{ flag: "--no-color", description: "Disable ANSI colors" },
|
|
139
|
+
{ flag: "--dry-run", description: "Show what would happen without executing" },
|
|
140
|
+
{ flag: "--non-interactive", description: "Disable interactive prompts (CI mode)" },
|
|
141
|
+
{ flag: "--help", description: "Show help" },
|
|
142
|
+
{ flag: "--version", description: "Print version" }
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
// ../forloop-opencode-plugin-planner/plugins/capabilities/token-storage.ts
|
|
146
|
+
import { promises as fs } from "fs";
|
|
147
|
+
import path from "path";
|
|
148
|
+
var getTokenPath = () => {
|
|
149
|
+
return path.join(
|
|
150
|
+
process.env.HOME || process.env.USERPROFILE || ".",
|
|
151
|
+
".config",
|
|
152
|
+
"forloop",
|
|
153
|
+
"tokens.json"
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
async function loadTokens() {
|
|
157
|
+
const tokenPath = getTokenPath();
|
|
158
|
+
try {
|
|
159
|
+
const content = await fs.readFile(tokenPath, "utf-8");
|
|
160
|
+
return JSON.parse(content);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (error.code === "ENOENT") {
|
|
163
|
+
return {
|
|
164
|
+
version: "1.0",
|
|
165
|
+
note: "ForLoop API tokens - do not share",
|
|
166
|
+
lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async function saveTokens(tokens) {
|
|
173
|
+
const tokenPath = getTokenPath();
|
|
174
|
+
const configDir = path.dirname(tokenPath);
|
|
175
|
+
await fs.mkdir(configDir, { recursive: true });
|
|
176
|
+
await fs.writeFile(
|
|
177
|
+
tokenPath,
|
|
178
|
+
JSON.stringify(tokens, null, 2),
|
|
179
|
+
{ mode: 384 }
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
async function getToken(profile) {
|
|
183
|
+
const tokens = await loadTokens();
|
|
184
|
+
if (profile && tokens.profiles?.[profile]) {
|
|
185
|
+
return tokens.profiles[profile];
|
|
186
|
+
}
|
|
187
|
+
if (tokens.default) return tokens.default;
|
|
188
|
+
if (tokens.profiles?.["default"]) return tokens.profiles["default"];
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
async function setToken(token, profile) {
|
|
192
|
+
const tokens = await loadTokens();
|
|
193
|
+
tokens.version = "1.0";
|
|
194
|
+
tokens.note = "ForLoop API tokens - do not share";
|
|
195
|
+
if (profile) {
|
|
196
|
+
if (!tokens.profiles) {
|
|
197
|
+
tokens.profiles = {};
|
|
198
|
+
}
|
|
199
|
+
tokens.profiles[profile] = token;
|
|
200
|
+
} else {
|
|
201
|
+
tokens.default = token;
|
|
202
|
+
}
|
|
203
|
+
tokens.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
|
|
204
|
+
await saveTokens(tokens);
|
|
205
|
+
}
|
|
206
|
+
async function clearToken(profile) {
|
|
207
|
+
const tokens = await loadTokens();
|
|
208
|
+
if (profile) {
|
|
209
|
+
if (tokens.profiles) {
|
|
210
|
+
delete tokens.profiles[profile];
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
delete tokens.default;
|
|
214
|
+
}
|
|
215
|
+
tokens.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
|
|
216
|
+
await saveTokens(tokens);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ../forloop-opencode-plugin-planner/plugins/capabilities/config.ts
|
|
220
|
+
import fs2 from "fs";
|
|
221
|
+
import path2 from "path";
|
|
222
|
+
var PROD_API_URL = "https://api.forloop.cc";
|
|
223
|
+
var DEV_API_URL = "https://api.dev.forloop.cc";
|
|
224
|
+
var PROD_TOKENS_URL = "https://forloop.cc/profile?tab=api-tokens";
|
|
225
|
+
var DEV_TOKENS_URL = "https://dev.forloop.cc/profile?tab=api-tokens";
|
|
226
|
+
var readJsonFileIfExists = (filePath) => {
|
|
227
|
+
try {
|
|
228
|
+
if (!fs2.existsSync(filePath)) return null;
|
|
229
|
+
const raw = fs2.readFileSync(filePath, "utf-8");
|
|
230
|
+
return JSON.parse(raw);
|
|
231
|
+
} catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
var getHomeDir = () => {
|
|
236
|
+
return process.env.HOME || process.env.USERPROFILE || "";
|
|
237
|
+
};
|
|
238
|
+
var getProjectConfig = () => {
|
|
239
|
+
return readJsonFileIfExists(path2.join(process.cwd(), "opencode.json"));
|
|
240
|
+
};
|
|
241
|
+
var getGlobalConfig = () => {
|
|
242
|
+
const home = getHomeDir();
|
|
243
|
+
if (!home) return null;
|
|
244
|
+
return readJsonFileIfExists(path2.join(home, ".config", "opencode", "config.json"));
|
|
245
|
+
};
|
|
246
|
+
var getNested = (obj, keys) => {
|
|
247
|
+
let cur = obj;
|
|
248
|
+
for (const key of keys) {
|
|
249
|
+
if (!cur || typeof cur !== "object") return void 0;
|
|
250
|
+
cur = cur[key];
|
|
251
|
+
}
|
|
252
|
+
return cur;
|
|
253
|
+
};
|
|
254
|
+
var isTruthy = (value) => {
|
|
255
|
+
if (typeof value === "boolean") return value;
|
|
256
|
+
if (typeof value === "string") return value.toLowerCase() === "true" || value === "1";
|
|
257
|
+
return false;
|
|
258
|
+
};
|
|
259
|
+
var normalizeUrl = (value) => {
|
|
260
|
+
if (typeof value !== "string") return null;
|
|
261
|
+
const trimmed = value.trim();
|
|
262
|
+
if (!trimmed) return null;
|
|
263
|
+
return trimmed;
|
|
264
|
+
};
|
|
265
|
+
var isDevUrl = (apiUrl) => {
|
|
266
|
+
return apiUrl.includes("api.dev.forloop.cc") || apiUrl.includes("dev.forloop.cc");
|
|
267
|
+
};
|
|
268
|
+
var resolveAllowDev = (projectConfig, globalConfig) => {
|
|
269
|
+
return isTruthy(process.env.FORLOOP_ALLOW_DEV) || isTruthy(getNested(projectConfig, ["forloop", "allowDev"])) || isTruthy(getNested(globalConfig, ["forloop", "allowDev"]));
|
|
270
|
+
};
|
|
271
|
+
var resolveApiUrl = (projectConfig, globalConfig, allowDev) => {
|
|
272
|
+
const envApiUrl = normalizeUrl(process.env.FORLOOP_API_URL || process.env.FORLOOP_API_BASE);
|
|
273
|
+
if (envApiUrl) {
|
|
274
|
+
if (isDevUrl(envApiUrl) && !allowDev) {
|
|
275
|
+
console.warn("[ForLoop Config] Dev API URL ignored (set FORLOOP_ALLOW_DEV=true to enable)");
|
|
276
|
+
return PROD_API_URL;
|
|
277
|
+
}
|
|
278
|
+
return envApiUrl;
|
|
279
|
+
}
|
|
280
|
+
const envName = normalizeUrl(process.env.FORLOOP_ENV);
|
|
281
|
+
if (envName) {
|
|
282
|
+
if (envName === "production") return PROD_API_URL;
|
|
283
|
+
if (envName === "development") {
|
|
284
|
+
if (!allowDev) {
|
|
285
|
+
console.warn("[ForLoop Config] FORLOOP_ENV=development ignored (set FORLOOP_ALLOW_DEV=true to enable)");
|
|
286
|
+
return PROD_API_URL;
|
|
287
|
+
}
|
|
288
|
+
return DEV_API_URL;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const projectApiUrl = normalizeUrl(getNested(projectConfig, ["forloop", "apiUrl"]));
|
|
292
|
+
if (projectApiUrl) {
|
|
293
|
+
if (isDevUrl(projectApiUrl) && !allowDev) {
|
|
294
|
+
console.warn("[ForLoop Config] Project dev API URL ignored (set forloop.allowDev=true or FORLOOP_ALLOW_DEV=true)");
|
|
295
|
+
return PROD_API_URL;
|
|
296
|
+
}
|
|
297
|
+
return projectApiUrl;
|
|
298
|
+
}
|
|
299
|
+
const globalApiUrl = normalizeUrl(getNested(globalConfig, ["forloop", "apiUrl"]));
|
|
300
|
+
if (globalApiUrl) {
|
|
301
|
+
if (isDevUrl(globalApiUrl) && !allowDev) {
|
|
302
|
+
console.warn("[ForLoop Config] Global dev API URL ignored (set forloop.allowDev=true or FORLOOP_ALLOW_DEV=true)");
|
|
303
|
+
return PROD_API_URL;
|
|
304
|
+
}
|
|
305
|
+
return globalApiUrl;
|
|
306
|
+
}
|
|
307
|
+
return PROD_API_URL;
|
|
308
|
+
};
|
|
309
|
+
var detectEnvironment = (apiUrl) => {
|
|
310
|
+
if (apiUrl.includes("dev.forloop.cc")) {
|
|
311
|
+
return "development";
|
|
312
|
+
}
|
|
313
|
+
if (apiUrl.includes("api.forloop.cc")) {
|
|
314
|
+
return "production";
|
|
315
|
+
}
|
|
316
|
+
return "custom";
|
|
317
|
+
};
|
|
318
|
+
var getConfig = () => {
|
|
319
|
+
const projectConfig = getProjectConfig();
|
|
320
|
+
const globalConfig = getGlobalConfig();
|
|
321
|
+
const allowDev = resolveAllowDev(projectConfig, globalConfig);
|
|
322
|
+
const apiUrl = resolveApiUrl(projectConfig, globalConfig, allowDev);
|
|
323
|
+
return {
|
|
324
|
+
apiUrl,
|
|
325
|
+
environment: detectEnvironment(apiUrl),
|
|
326
|
+
tokenStorage: "global",
|
|
327
|
+
debug: process.env.FORLOOP_DEBUG === "true",
|
|
328
|
+
allowDev
|
|
329
|
+
};
|
|
330
|
+
};
|
|
331
|
+
var getTokensUrl = () => {
|
|
332
|
+
const config = getConfig();
|
|
333
|
+
if (config.environment === "development") return DEV_TOKENS_URL;
|
|
334
|
+
return PROD_TOKENS_URL;
|
|
335
|
+
};
|
|
336
|
+
var isLambdaExecution = () => {
|
|
337
|
+
const forced = (process.env.FORLOOP_EXECUTION_CONTEXT || "").toLowerCase();
|
|
338
|
+
if (forced === "lambda" || forced === "aws_lambda" || forced === "aws-lambda") return true;
|
|
339
|
+
return !!(process.env.AWS_LAMBDA_FUNCTION_NAME || process.env.AWS_EXECUTION_ENV || process.env.LAMBDA_TASK_ROOT);
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
// ../forloop-opencode-plugin-planner/plugins/capabilities/auth.ts
|
|
343
|
+
function detectTokenType(token) {
|
|
344
|
+
if (!token) return null;
|
|
345
|
+
if (token.startsWith("floop_")) {
|
|
346
|
+
return "api_key";
|
|
347
|
+
}
|
|
348
|
+
const parts = token.split(".");
|
|
349
|
+
if (parts.length === 3 && parts.every((p) => p.length > 0)) {
|
|
350
|
+
const base64urlRegex = /^[A-Za-z0-9_-]+$/;
|
|
351
|
+
if (parts.every((p) => base64urlRegex.test(p))) {
|
|
352
|
+
return "jwt";
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
async function validateToken(providedToken) {
|
|
358
|
+
const token = providedToken || await getToken();
|
|
359
|
+
if (!token) {
|
|
360
|
+
return {
|
|
361
|
+
valid: false,
|
|
362
|
+
error: "No API token configured. Please create a token at " + getTokensUrl()
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
const tokenType = detectTokenType(token);
|
|
366
|
+
if (!tokenType) {
|
|
367
|
+
return {
|
|
368
|
+
valid: false,
|
|
369
|
+
error: "Unrecognized token format. Use either a ForLoop API key (floop_*) or a valid JWT token."
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
if (tokenType === "jwt") {
|
|
373
|
+
const isLambda = isLambdaExecution();
|
|
374
|
+
if (!isLambda) {
|
|
375
|
+
return {
|
|
376
|
+
valid: false,
|
|
377
|
+
error: "JWT tokens are only supported in Lambda execution. Please use a ForLoop API key (floop_*) for local execution."
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
valid: true,
|
|
382
|
+
token,
|
|
383
|
+
tokenType: "jwt"
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
valid: true,
|
|
388
|
+
token,
|
|
389
|
+
tokenType: "api_key"
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ../forloop-opencode-plugin-planner/plugins/capabilities/api-client.ts
|
|
394
|
+
var APIError = class extends Error {
|
|
395
|
+
constructor(code, message, status) {
|
|
396
|
+
super(message);
|
|
397
|
+
this.code = code;
|
|
398
|
+
this.status = status;
|
|
399
|
+
this.name = "APIError";
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
var ForLoopAPIClient = class {
|
|
403
|
+
config;
|
|
404
|
+
defaultHeaders;
|
|
405
|
+
tokenType;
|
|
406
|
+
jwtExchangePromise;
|
|
407
|
+
constructor(config) {
|
|
408
|
+
this.config = {
|
|
409
|
+
timeout: 3e4,
|
|
410
|
+
retryCount: 3,
|
|
411
|
+
...config
|
|
412
|
+
};
|
|
413
|
+
this.defaultHeaders = {
|
|
414
|
+
"Content-Type": "application/json"
|
|
415
|
+
};
|
|
416
|
+
this.tokenType = detectTokenType(config.token);
|
|
417
|
+
this.jwtExchangePromise = null;
|
|
418
|
+
}
|
|
419
|
+
setToken(token) {
|
|
420
|
+
this.config.token = token;
|
|
421
|
+
this.tokenType = detectTokenType(token);
|
|
422
|
+
}
|
|
423
|
+
async ensureOpencodeApiToken(endpoint) {
|
|
424
|
+
if (this.tokenType !== "jwt") return;
|
|
425
|
+
if (!endpoint.startsWith("/api/opencode/")) return;
|
|
426
|
+
if (endpoint.startsWith("/api/opencode/auth/tokens")) return;
|
|
427
|
+
if (!this.jwtExchangePromise) {
|
|
428
|
+
this.jwtExchangePromise = this.exchangeJwtForApiToken();
|
|
429
|
+
}
|
|
430
|
+
const token = await this.jwtExchangePromise;
|
|
431
|
+
this.setToken(token);
|
|
432
|
+
}
|
|
433
|
+
async exchangeJwtForApiToken() {
|
|
434
|
+
const url = `${this.config.baseUrl}/api/opencode/auth/tokens`;
|
|
435
|
+
const jwt = this.config.token;
|
|
436
|
+
const scopes = [
|
|
437
|
+
"sprint:read",
|
|
438
|
+
"sprint:write",
|
|
439
|
+
"story:read",
|
|
440
|
+
"story:write",
|
|
441
|
+
"story:delete",
|
|
442
|
+
"agent:read",
|
|
443
|
+
"agent:query",
|
|
444
|
+
"profile:read",
|
|
445
|
+
"organization:read",
|
|
446
|
+
"organization:write",
|
|
447
|
+
"organization:delete"
|
|
448
|
+
];
|
|
449
|
+
const name = `lambda-planner-${Date.now()}`;
|
|
450
|
+
const response = await fetch(url, {
|
|
451
|
+
method: "POST",
|
|
452
|
+
headers: {
|
|
453
|
+
...this.defaultHeaders,
|
|
454
|
+
Authorization: `Bearer ${jwt}`
|
|
455
|
+
},
|
|
456
|
+
body: JSON.stringify({
|
|
457
|
+
name,
|
|
458
|
+
expiresIn: "7d",
|
|
459
|
+
scopes
|
|
460
|
+
}),
|
|
461
|
+
signal: AbortSignal.timeout(this.config.timeout)
|
|
462
|
+
});
|
|
463
|
+
if (!response.ok) {
|
|
464
|
+
throw await this.parseError(response);
|
|
465
|
+
}
|
|
466
|
+
const payload = await response.json();
|
|
467
|
+
const token = typeof payload?.token === "string" ? payload.token.trim() : "";
|
|
468
|
+
if (!token) {
|
|
469
|
+
throw new APIError("invalid_token_exchange", "Token exchange returned empty token", 500);
|
|
470
|
+
}
|
|
471
|
+
await setToken(token);
|
|
472
|
+
return token;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Get auth headers based on token type
|
|
476
|
+
* - JWT tokens: Authorization: Bearer <token>
|
|
477
|
+
* - API keys: X-API-Token: <token>
|
|
478
|
+
*/
|
|
479
|
+
getAuthHeaders() {
|
|
480
|
+
const headers = {};
|
|
481
|
+
if (this.tokenType === "jwt") {
|
|
482
|
+
headers["Authorization"] = `Bearer ${this.config.token}`;
|
|
483
|
+
} else {
|
|
484
|
+
headers["X-API-Token"] = this.config.token;
|
|
485
|
+
}
|
|
486
|
+
return headers;
|
|
487
|
+
}
|
|
488
|
+
async request(endpoint, options = {}) {
|
|
489
|
+
await this.ensureOpencodeApiToken(endpoint);
|
|
490
|
+
const url = `${this.config.baseUrl}${endpoint}`;
|
|
491
|
+
let lastError = null;
|
|
492
|
+
for (let attempt = 1; attempt <= this.config.retryCount; attempt++) {
|
|
493
|
+
try {
|
|
494
|
+
const response = await fetch(url, {
|
|
495
|
+
...options,
|
|
496
|
+
headers: {
|
|
497
|
+
...this.defaultHeaders,
|
|
498
|
+
...this.getAuthHeaders(),
|
|
499
|
+
// Dynamic auth headers based on token type
|
|
500
|
+
...options.headers || {}
|
|
501
|
+
},
|
|
502
|
+
signal: AbortSignal.timeout(this.config.timeout)
|
|
503
|
+
});
|
|
504
|
+
if (!response.ok) {
|
|
505
|
+
const error = await this.parseError(response);
|
|
506
|
+
throw error;
|
|
507
|
+
}
|
|
508
|
+
return response.json();
|
|
509
|
+
} catch (error) {
|
|
510
|
+
lastError = error;
|
|
511
|
+
if (error instanceof APIError && error.status >= 400 && error.status < 500) {
|
|
512
|
+
throw error;
|
|
513
|
+
}
|
|
514
|
+
if (attempt < this.config.retryCount) {
|
|
515
|
+
await this.sleep(Math.pow(2, attempt) * 100);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
throw lastError || new Error("Request failed");
|
|
520
|
+
}
|
|
521
|
+
async parseError(response) {
|
|
522
|
+
try {
|
|
523
|
+
const body = await response.json();
|
|
524
|
+
return new APIError(body.error || "UnknownError", body.message || response.statusText, response.status);
|
|
525
|
+
} catch {
|
|
526
|
+
return new APIError("UnknownError", response.statusText, response.status);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
sleep(ms) {
|
|
530
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
531
|
+
}
|
|
532
|
+
async listSprints(params) {
|
|
533
|
+
const query = new URLSearchParams(params);
|
|
534
|
+
const response = await this.request(`/api/opencode/sprints?${query}`);
|
|
535
|
+
return Array.isArray(response) ? response : response.data || [];
|
|
536
|
+
}
|
|
537
|
+
async getSprint(id, options) {
|
|
538
|
+
const query = new URLSearchParams({
|
|
539
|
+
includeStories: options?.includeStories !== false ? "true" : "false",
|
|
540
|
+
includeFiles: options?.includeFiles !== false ? "true" : "false"
|
|
541
|
+
});
|
|
542
|
+
return this.request(`/api/opencode/sprints/${id}?${query}`);
|
|
543
|
+
}
|
|
544
|
+
async createSprint(data) {
|
|
545
|
+
return this.request("/api/opencode/sprints", {
|
|
546
|
+
method: "POST",
|
|
547
|
+
body: JSON.stringify(data)
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
async updateSprint(id, data) {
|
|
551
|
+
return this.request(`/api/opencode/sprints/${id}`, {
|
|
552
|
+
method: "PUT",
|
|
553
|
+
body: JSON.stringify(data)
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
async deleteSprint(id) {
|
|
557
|
+
await this.request(`/api/opencode/sprints/${id}`, {
|
|
558
|
+
method: "DELETE"
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
async createStory(data) {
|
|
562
|
+
return this.request("/api/opencode/stories", {
|
|
563
|
+
method: "POST",
|
|
564
|
+
body: JSON.stringify(data)
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
async getStory(id, options) {
|
|
568
|
+
const params = new URLSearchParams();
|
|
569
|
+
if (options?.includeComments) params.set("includeComments", "true");
|
|
570
|
+
const query = params.toString() ? `?${params.toString()}` : "";
|
|
571
|
+
return this.request(`/api/opencode/stories/${id}${query}`);
|
|
572
|
+
}
|
|
573
|
+
async updateStory(id, data) {
|
|
574
|
+
return this.request(`/api/opencode/stories/${id}`, {
|
|
575
|
+
method: "PUT",
|
|
576
|
+
body: JSON.stringify(data)
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
async deleteStory(id) {
|
|
580
|
+
await this.request(`/api/opencode/stories/${id}`, {
|
|
581
|
+
method: "DELETE"
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
async listTemplates() {
|
|
585
|
+
return this.request("/api/opencode/templates");
|
|
586
|
+
}
|
|
587
|
+
async getUserProfile() {
|
|
588
|
+
return this.request("/api/opencode/user/profile");
|
|
589
|
+
}
|
|
590
|
+
async getUserQuotas() {
|
|
591
|
+
return this.request("/api/opencode/user/quotas");
|
|
592
|
+
}
|
|
593
|
+
async getOrganizationQuotas(orgId) {
|
|
594
|
+
return this.request(`/api/opencode/organization/${orgId}/quotas`);
|
|
595
|
+
}
|
|
596
|
+
async listOrganizations() {
|
|
597
|
+
return this.request("/api/opencode/organizations");
|
|
598
|
+
}
|
|
599
|
+
async getOwnedOrganizations() {
|
|
600
|
+
return this.request("/api/opencode/organizations/owned");
|
|
601
|
+
}
|
|
602
|
+
async getOrganization(id) {
|
|
603
|
+
return this.request(`/api/opencode/organizations/${id}`);
|
|
604
|
+
}
|
|
605
|
+
async createOrganization(data) {
|
|
606
|
+
return this.request("/api/opencode/organizations", {
|
|
607
|
+
method: "POST",
|
|
608
|
+
body: JSON.stringify(data)
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
async updateOrganization(id, data) {
|
|
612
|
+
return this.request(`/api/opencode/organizations/${id}`, {
|
|
613
|
+
method: "PUT",
|
|
614
|
+
body: JSON.stringify(data)
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
async deleteOrganization(id) {
|
|
618
|
+
await this.request(`/api/opencode/organizations/${id}`, {
|
|
619
|
+
method: "DELETE"
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
async chatWithAI(request) {
|
|
623
|
+
return this.request("/api/opencode/agents/chat", {
|
|
624
|
+
method: "POST",
|
|
625
|
+
body: JSON.stringify(request)
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
async getDeveloperStatus(sprintId) {
|
|
629
|
+
return this.request(`/api/opencode/sprints/${sprintId}/developer-status`);
|
|
630
|
+
}
|
|
631
|
+
async getStorySuggestions(sprintId, storyId) {
|
|
632
|
+
return this.request("/api/opencode/agents/suggestions", {
|
|
633
|
+
method: "POST",
|
|
634
|
+
body: JSON.stringify({ sprintId, storyId })
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
async getAITasks(sprintId) {
|
|
638
|
+
const query = sprintId ? new URLSearchParams({ sprintId: String(sprintId) }) : new URLSearchParams();
|
|
639
|
+
return this.request(`/api/opencode/agents/tasks?${query}`);
|
|
640
|
+
}
|
|
641
|
+
async getSprintSummaries(sprintId) {
|
|
642
|
+
return this.request(`/api/opencode/agents/summary?sprintId=${sprintId}`);
|
|
643
|
+
}
|
|
644
|
+
async getAgentSuggestions(request) {
|
|
645
|
+
return this.request("/api/agents/suggestions", {
|
|
646
|
+
method: "POST",
|
|
647
|
+
body: JSON.stringify(request)
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
async getStoryBreakdown(storyId) {
|
|
651
|
+
return this.request(`/api/agents/stories/${storyId}/breakdown`);
|
|
652
|
+
}
|
|
653
|
+
async getStoryEstimate(storyId) {
|
|
654
|
+
return this.request(`/api/agents/stories/${storyId}/estimate`);
|
|
655
|
+
}
|
|
656
|
+
async getConversationHistory(params) {
|
|
657
|
+
const query = new URLSearchParams();
|
|
658
|
+
if (params?.sprintId) query.append("sprintId", String(params.sprintId));
|
|
659
|
+
if (params?.limit) query.append("limit", String(params.limit));
|
|
660
|
+
return this.request(`/api/opencode/messages?${query}`);
|
|
661
|
+
}
|
|
662
|
+
async clearConversationHistory(params) {
|
|
663
|
+
const query = new URLSearchParams();
|
|
664
|
+
if (params?.sprintId) query.append("sprintId", String(params.sprintId));
|
|
665
|
+
query.append("confirm", "true");
|
|
666
|
+
return this.request(`/api/opencode/messages?${query}`, {
|
|
667
|
+
method: "DELETE"
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
async createPresignedUpload(data) {
|
|
671
|
+
return this.request("/api/opencode/files/presign", {
|
|
672
|
+
method: "POST",
|
|
673
|
+
body: JSON.stringify(data)
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
async completeUpload(data) {
|
|
677
|
+
return this.request("/api/opencode/files/complete", {
|
|
678
|
+
method: "POST",
|
|
679
|
+
body: JSON.stringify(data)
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
async getSprintFiles(sprintId) {
|
|
683
|
+
return this.request(`/api/opencode/files/sprint/${sprintId}`);
|
|
684
|
+
}
|
|
685
|
+
async deleteFile(fileId) {
|
|
686
|
+
await this.request(`/api/opencode/files/${fileId}`, {
|
|
687
|
+
method: "DELETE"
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
async logFileRead(fileId) {
|
|
691
|
+
await this.request(`/api/opencode/files/${fileId}/read`, {
|
|
692
|
+
method: "POST"
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
async getFileDownloadUrl(fileId) {
|
|
696
|
+
return this.request(`/api/opencode/files/download/${fileId}`);
|
|
697
|
+
}
|
|
698
|
+
async getFileOpenUrl(fileId) {
|
|
699
|
+
return this.request(`/api/opencode/files/open-url/${fileId}`);
|
|
700
|
+
}
|
|
701
|
+
async updateFilePosition(fileId, data) {
|
|
702
|
+
return this.request(`/api/opencode/files/${fileId}/position`, {
|
|
703
|
+
method: "PUT",
|
|
704
|
+
body: JSON.stringify(data)
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
async listAiAgents() {
|
|
708
|
+
const response = await this.request("/api/opencode/ai/agents");
|
|
709
|
+
return response.agents || [];
|
|
710
|
+
}
|
|
711
|
+
async updateSprintAiAgents(sprintId, enabledAgentKeys) {
|
|
712
|
+
const response = await this.request(`/api/opencode/sprints/${sprintId}/ai-agents`, {
|
|
713
|
+
method: "PUT",
|
|
714
|
+
body: JSON.stringify({ enabledAgentKeys })
|
|
715
|
+
});
|
|
716
|
+
return response.sprintAiAgents || [];
|
|
717
|
+
}
|
|
718
|
+
async updateSchedule(storyId, data) {
|
|
719
|
+
return this.updateStory(storyId, {
|
|
720
|
+
title: data.title,
|
|
721
|
+
description: data.description,
|
|
722
|
+
metadata: data
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
async recordMessage(data) {
|
|
726
|
+
return this.request("/api/opencode/messages", {
|
|
727
|
+
method: "POST",
|
|
728
|
+
body: JSON.stringify(data)
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
async removeMessage(messageId, sprintId, sessionId) {
|
|
732
|
+
await this.request(`/api/opencode/messages/${encodeURIComponent(messageId)}`, {
|
|
733
|
+
method: "DELETE",
|
|
734
|
+
body: JSON.stringify({ sprintId, sessionId })
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
// ../forloop-opencode-plugin-planner/plugins/tools/sprint-tools.ts
|
|
740
|
+
import { tool } from "@opencode-ai/plugin";
|
|
741
|
+
|
|
742
|
+
// ../forloop-opencode-plugin-planner/plugins/capabilities/context-resolver.ts
|
|
743
|
+
import { exec } from "child_process";
|
|
744
|
+
import { promisify } from "util";
|
|
745
|
+
import * as fs3 from "fs";
|
|
746
|
+
import * as path3 from "path";
|
|
747
|
+
var execAsync = promisify(exec);
|
|
748
|
+
async function getCurrentBranch(directory) {
|
|
749
|
+
try {
|
|
750
|
+
const result = await execAsync("git rev-parse --abbrev-ref HEAD", {
|
|
751
|
+
cwd: directory
|
|
752
|
+
});
|
|
753
|
+
return result.stdout.trim();
|
|
754
|
+
} catch (error) {
|
|
755
|
+
throw new Error("Git not available or not in a repository");
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
async function resolveSprintFromBranch(branch) {
|
|
759
|
+
const match = branch.match(/sprint-(\d+)/);
|
|
760
|
+
if (match) {
|
|
761
|
+
return parseInt(match[1], 10);
|
|
762
|
+
}
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
async function resolveSprintId(providedId, directory) {
|
|
766
|
+
if (providedId) {
|
|
767
|
+
return { sprintId: providedId, source: "provided" };
|
|
768
|
+
}
|
|
769
|
+
const envSprintId = process.env.FORLOOP_SPRINT_ID;
|
|
770
|
+
if (envSprintId) {
|
|
771
|
+
return { sprintId: parseInt(envSprintId, 10), source: "env" };
|
|
772
|
+
}
|
|
773
|
+
const manifestSprintId = getActiveSprintIdFromManifest(directory);
|
|
774
|
+
if (manifestSprintId) {
|
|
775
|
+
return { sprintId: manifestSprintId, source: "manifest" };
|
|
776
|
+
}
|
|
777
|
+
try {
|
|
778
|
+
const branch = await getCurrentBranch(directory);
|
|
779
|
+
const sprintId = await resolveSprintFromBranch(branch);
|
|
780
|
+
if (sprintId) {
|
|
781
|
+
return { sprintId, branch, source: "branch" };
|
|
782
|
+
}
|
|
783
|
+
} catch (error) {
|
|
784
|
+
}
|
|
785
|
+
return { source: "config" };
|
|
786
|
+
}
|
|
787
|
+
function getActiveSprintIdFromManifest(directory) {
|
|
788
|
+
const candidates = [];
|
|
789
|
+
if (directory) {
|
|
790
|
+
candidates.push(path3.join(directory, ".forloop", "manifest.json"));
|
|
791
|
+
}
|
|
792
|
+
const home = process.env.HOME;
|
|
793
|
+
if (home) {
|
|
794
|
+
candidates.push(path3.join(home, ".forloop", "manifest.json"));
|
|
795
|
+
}
|
|
796
|
+
for (const filePath of candidates) {
|
|
797
|
+
try {
|
|
798
|
+
if (!fs3.existsSync(filePath)) continue;
|
|
799
|
+
const raw = fs3.readFileSync(filePath, "utf8");
|
|
800
|
+
const parsed = JSON.parse(raw);
|
|
801
|
+
const id = parsed?.activeSprintId;
|
|
802
|
+
if (Number.isFinite(id)) return Number(id);
|
|
803
|
+
} catch {
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return null;
|
|
807
|
+
}
|
|
808
|
+
var DEFAULT_HOME = "/tmp/home";
|
|
809
|
+
function getSprintDir(sprintId) {
|
|
810
|
+
return `sprint-${sprintId}`;
|
|
811
|
+
}
|
|
812
|
+
function getForloopRoot(sprintId) {
|
|
813
|
+
const home = process.env.HOME || DEFAULT_HOME;
|
|
814
|
+
const root = path3.join(home, ".forloop");
|
|
815
|
+
if (sprintId !== void 0) {
|
|
816
|
+
return path3.join(root, getSprintDir(sprintId));
|
|
817
|
+
}
|
|
818
|
+
return root;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// ../forloop-opencode-plugin-planner/plugins/tools/story-tools.ts
|
|
822
|
+
import { tool as tool2 } from "@opencode-ai/plugin";
|
|
823
|
+
|
|
824
|
+
// ../forloop-opencode-plugin-planner/plugins/tools/template-tools.ts
|
|
825
|
+
import { tool as tool3 } from "@opencode-ai/plugin";
|
|
826
|
+
|
|
827
|
+
// ../forloop-opencode-plugin-planner/plugins/tools/user-tools.ts
|
|
828
|
+
import { tool as tool4 } from "@opencode-ai/plugin";
|
|
829
|
+
|
|
830
|
+
// ../forloop-opencode-plugin-planner/plugins/tools/organization-tools.ts
|
|
831
|
+
import { tool as tool5 } from "@opencode-ai/plugin";
|
|
832
|
+
|
|
833
|
+
// ../forloop-opencode-plugin-planner/plugins/tools/file-tools.ts
|
|
834
|
+
import { tool as tool6 } from "@opencode-ai/plugin";
|
|
835
|
+
|
|
836
|
+
// ../forloop-opencode-plugin-planner/plugins/tools/schedule-tools.ts
|
|
837
|
+
import { tool as tool7 } from "@opencode-ai/plugin";
|
|
838
|
+
|
|
839
|
+
// ../forloop-opencode-plugin-planner/plugins/tools/folder-tools.ts
|
|
840
|
+
import { tool as tool8 } from "@opencode-ai/plugin";
|
|
841
|
+
|
|
842
|
+
// ../forloop-opencode-plugin-planner/plugins/tools/sync-tools.ts
|
|
843
|
+
import { tool as tool9 } from "@opencode-ai/plugin";
|
|
844
|
+
|
|
845
|
+
// ../forloop-opencode-plugin-planner/plugins/tools/agent-tools.ts
|
|
846
|
+
import { tool as tool10 } from "@opencode-ai/plugin";
|
|
847
|
+
|
|
848
|
+
// ../forloop-opencode-plugin-planner/plugins/tools/agent-suggestion-tools.ts
|
|
849
|
+
import { tool as tool11 } from "@opencode-ai/plugin";
|
|
850
|
+
|
|
851
|
+
// ../forloop-opencode-plugin-planner/plugins/tools/agent-sprint-tools.ts
|
|
852
|
+
import { tool as tool12 } from "@opencode-ai/plugin";
|
|
853
|
+
|
|
854
|
+
// ../forloop-opencode-plugin-planner/plugins/tools/ai-trigger-tools.ts
|
|
855
|
+
import { tool as tool13 } from "@opencode-ai/plugin";
|
|
856
|
+
|
|
857
|
+
// ../forloop-opencode-plugin-planner/plugins/tools/developer-status-tool.ts
|
|
858
|
+
import { tool as tool14 } from "@opencode-ai/plugin";
|
|
859
|
+
|
|
860
|
+
// ../forloop-opencode-plugin-planner/plugins/hooks/shell-env.ts
|
|
861
|
+
import { exec as exec2 } from "child_process";
|
|
862
|
+
import { promisify as promisify2 } from "util";
|
|
863
|
+
var execAsync2 = promisify2(exec2);
|
|
864
|
+
|
|
865
|
+
// src/commands/auth/index.ts
|
|
866
|
+
function createAuthCommands() {
|
|
867
|
+
return {
|
|
868
|
+
"auth login": createAuthLoginCommand(),
|
|
869
|
+
"auth status": createAuthStatusCommand(),
|
|
870
|
+
"auth logout": createAuthLogoutCommand()
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
function createAuthLoginCommand() {
|
|
874
|
+
return defineCommand({
|
|
875
|
+
name: "auth login",
|
|
876
|
+
description: "Authenticate with ForLoop API key",
|
|
877
|
+
usage: "forloop auth login --api-key <token>",
|
|
878
|
+
options: [
|
|
879
|
+
{ flag: "--api-key <token>", description: "ForLoop API token (starts with floop_)", required: true }
|
|
880
|
+
],
|
|
881
|
+
examples: [
|
|
882
|
+
"forloop auth login --api-key floop_xxxx"
|
|
883
|
+
],
|
|
884
|
+
async run(_config, flags) {
|
|
885
|
+
const apiKey = flags.apiKey;
|
|
886
|
+
if (!apiKey || !apiKey.startsWith("floop_")) {
|
|
887
|
+
console.error('Error: --api-key is required and must start with "floop_"');
|
|
888
|
+
process.exit(1);
|
|
889
|
+
}
|
|
890
|
+
const validation = await validateToken(apiKey);
|
|
891
|
+
if (!validation.valid) {
|
|
892
|
+
console.error(`Invalid token: ${validation.error}`);
|
|
893
|
+
process.exit(1);
|
|
894
|
+
}
|
|
895
|
+
await setToken(apiKey);
|
|
896
|
+
console.log("Authenticated successfully. Token saved to ~/.config/forloop/tokens.json");
|
|
897
|
+
}
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
function createAuthStatusCommand() {
|
|
901
|
+
return defineCommand({
|
|
902
|
+
name: "auth status",
|
|
903
|
+
description: "Check current authentication status",
|
|
904
|
+
usage: "forloop auth status",
|
|
905
|
+
async run(_config) {
|
|
906
|
+
const token = await getToken();
|
|
907
|
+
if (token) {
|
|
908
|
+
const masked = token.slice(0, 8) + "..." + token.slice(-4);
|
|
909
|
+
const validation = await validateToken(token);
|
|
910
|
+
console.log(`Token: ${masked}`);
|
|
911
|
+
console.log(`Format: ${validation.valid ? "valid" : "invalid"}`);
|
|
912
|
+
} else {
|
|
913
|
+
console.log("Not authenticated");
|
|
914
|
+
console.log("Run: forloop auth login --api-key <token>");
|
|
915
|
+
process.exit(1);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
function createAuthLogoutCommand() {
|
|
921
|
+
return defineCommand({
|
|
922
|
+
name: "auth logout",
|
|
923
|
+
description: "Clear saved authentication token",
|
|
924
|
+
usage: "forloop auth logout",
|
|
925
|
+
async run(_config) {
|
|
926
|
+
await clearToken();
|
|
927
|
+
console.log("Logged out. Token cleared.");
|
|
928
|
+
}
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// src/commands/story.ts
|
|
933
|
+
function createStoryCommands() {
|
|
934
|
+
return {
|
|
935
|
+
"story create": createStoryCreateCommand(),
|
|
936
|
+
"story get": createStoryGetCommand(),
|
|
937
|
+
"story update": createStoryUpdateCommand(),
|
|
938
|
+
"story delete": createStoryDeleteCommand()
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
async function getClient() {
|
|
942
|
+
const config = getConfig();
|
|
943
|
+
const token = await getToken();
|
|
944
|
+
if (!token) {
|
|
945
|
+
console.error("Error: No API token configured. Run: forloop auth login --api-key <token>");
|
|
946
|
+
process.exit(1);
|
|
947
|
+
}
|
|
948
|
+
const client = new ForLoopAPIClient({ token, baseUrl: config.apiUrl });
|
|
949
|
+
return { client };
|
|
950
|
+
}
|
|
951
|
+
function createStoryCreateCommand() {
|
|
952
|
+
return defineCommand({
|
|
953
|
+
name: "story create",
|
|
954
|
+
description: "Create a story. Use --type basic-task|basic-note for template-based stories, or omit for doc_folder.",
|
|
955
|
+
usage: "forloop story create --title <text> --sprint <id> [--type basic-task|basic-note] [flags]",
|
|
956
|
+
options: [
|
|
957
|
+
{ flag: "--title <text>", description: "Story title", required: true },
|
|
958
|
+
{ flag: "--sprint <id>", description: "Target sprint ID", required: true, type: "number" },
|
|
959
|
+
{ flag: "--type <type>", description: "basic-task or basic-note. Omit for doc_folder." },
|
|
960
|
+
{ flag: "--description <text>", description: "Story description" },
|
|
961
|
+
{ flag: "--priority <priority>", description: "low, medium, high, critical (default: medium)" },
|
|
962
|
+
{ flag: "--points <n>", description: "Story points (0-10)", type: "number" },
|
|
963
|
+
{ flag: "--assignee-agent <key>", description: "AI agent key to assign to" }
|
|
964
|
+
],
|
|
965
|
+
examples: [
|
|
966
|
+
'forloop story create --title "Implement login" --sprint 1 --type basic-task --priority high --points 3',
|
|
967
|
+
'forloop story create --title "Meeting notes" --sprint 1 --type basic-note',
|
|
968
|
+
'forloop story create --title "Design Docs" --sprint 1'
|
|
969
|
+
],
|
|
970
|
+
async run(_config, flags) {
|
|
971
|
+
const resolved = await getClient();
|
|
972
|
+
if (!resolved) return;
|
|
973
|
+
const { client } = resolved;
|
|
974
|
+
const sprintId = flags.sprint;
|
|
975
|
+
if (!sprintId || !flags.title) {
|
|
976
|
+
console.error("Error: --title and --sprint are required");
|
|
977
|
+
process.exit(1);
|
|
978
|
+
}
|
|
979
|
+
const templateSlug = flags.type;
|
|
980
|
+
if (templateSlug) {
|
|
981
|
+
if (templateSlug !== "basic-task" && templateSlug !== "basic-note") {
|
|
982
|
+
console.error('Error: --type must be "basic-task" or "basic-note"');
|
|
983
|
+
process.exit(1);
|
|
984
|
+
}
|
|
985
|
+
const templates = await client.listTemplates();
|
|
986
|
+
const template = templates.find((t) => t.slug === templateSlug && !t.deletedAt);
|
|
987
|
+
if (!template) {
|
|
988
|
+
console.error(`Error: Template "${templateSlug}" not found`);
|
|
989
|
+
process.exit(1);
|
|
990
|
+
}
|
|
991
|
+
const story = await client.createStory({
|
|
992
|
+
title: flags.title,
|
|
993
|
+
description: flags.description,
|
|
994
|
+
sprintId,
|
|
995
|
+
type: template.storyType || "task",
|
|
996
|
+
templateId: template.id,
|
|
997
|
+
priority: flags.priority || "medium",
|
|
998
|
+
points: flags.points,
|
|
999
|
+
assigneeAgentKey: flags.assigneeAgent,
|
|
1000
|
+
assigneeType: flags.assigneeAgent ? "agent" : void 0,
|
|
1001
|
+
status: "todo",
|
|
1002
|
+
metadata: JSON.stringify({
|
|
1003
|
+
taskTitle: flags.title,
|
|
1004
|
+
description: flags.description,
|
|
1005
|
+
status: "not-started",
|
|
1006
|
+
priority: flags.priority || "medium",
|
|
1007
|
+
points: flags.points
|
|
1008
|
+
})
|
|
1009
|
+
});
|
|
1010
|
+
console.log(`Story created: #${story.id} - ${story.title} (${story.type})`);
|
|
1011
|
+
} else {
|
|
1012
|
+
const story = await client.createStory({
|
|
1013
|
+
title: flags.title,
|
|
1014
|
+
description: flags.description,
|
|
1015
|
+
sprintId,
|
|
1016
|
+
type: "doc_folder",
|
|
1017
|
+
priority: flags.priority || "medium",
|
|
1018
|
+
status: "todo"
|
|
1019
|
+
});
|
|
1020
|
+
console.log(`Doc folder created: #${story.id} - ${story.title}`);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
function createStoryGetCommand() {
|
|
1026
|
+
return defineCommand({
|
|
1027
|
+
name: "story get",
|
|
1028
|
+
description: "Get story details including sprint info, assignee, and comments",
|
|
1029
|
+
usage: "forloop story get --id <number>",
|
|
1030
|
+
options: [
|
|
1031
|
+
{ flag: "--id <number>", description: "Story ID", required: true, type: "number" },
|
|
1032
|
+
{ flag: "--no-comments", description: "Exclude comments" }
|
|
1033
|
+
],
|
|
1034
|
+
examples: ["forloop story get --id 123"],
|
|
1035
|
+
async run(_config, flags) {
|
|
1036
|
+
const resolved = await getClient();
|
|
1037
|
+
if (!resolved) return;
|
|
1038
|
+
const { client } = resolved;
|
|
1039
|
+
const storyId = flags.id;
|
|
1040
|
+
if (!storyId) {
|
|
1041
|
+
console.error("Error: --id is required");
|
|
1042
|
+
process.exit(1);
|
|
1043
|
+
}
|
|
1044
|
+
const story = await client.getStory(storyId, { includeComments: !flags.noComments });
|
|
1045
|
+
const lines = [
|
|
1046
|
+
`Story #${story.id}: ${story.title}`,
|
|
1047
|
+
`Sprint: #${story.sprint?.id || "N/A"} ${story.sprint?.title || ""}`,
|
|
1048
|
+
`Status: ${story.status}`,
|
|
1049
|
+
`Priority: ${story.priority}`,
|
|
1050
|
+
`Type: ${story.type}`
|
|
1051
|
+
];
|
|
1052
|
+
if (story.points) lines.push(`Points: ${story.points}`);
|
|
1053
|
+
if (story.assignee) lines.push(`Assignee: ${story.assignee.name || story.assignee.email || "Unknown"}`);
|
|
1054
|
+
if (story.assigneeAgentKey) lines.push(`Agent: ${story.assigneeAgentKey}`);
|
|
1055
|
+
if (story.description) {
|
|
1056
|
+
lines.push("", "Description:", story.description);
|
|
1057
|
+
}
|
|
1058
|
+
if (!flags.noComments && story.comments?.length) {
|
|
1059
|
+
lines.push("", `--- Comments (${story.comments.length}) ---`);
|
|
1060
|
+
for (const comment of story.comments) {
|
|
1061
|
+
const author = comment.authorAgentKey || comment.user?.name || "Unknown";
|
|
1062
|
+
lines.push(` ${author}: ${comment.content || comment.body || ""}`);
|
|
1063
|
+
if (comment.artifacts?.length) lines.push(` Artifacts: ${comment.artifacts.join(", ")}`);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
console.log(lines.join("\n"));
|
|
1067
|
+
}
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
function createStoryUpdateCommand() {
|
|
1071
|
+
return defineCommand({
|
|
1072
|
+
name: "story update",
|
|
1073
|
+
description: "Update story fields",
|
|
1074
|
+
usage: "forloop story update --id <number> [flags]",
|
|
1075
|
+
options: [
|
|
1076
|
+
{ flag: "--id <number>", description: "Story ID", required: true, type: "number" },
|
|
1077
|
+
{ flag: "--title <text>", description: "New title" },
|
|
1078
|
+
{ flag: "--description <text>", description: "New description" },
|
|
1079
|
+
{ flag: "--status <status>", description: "todo, in_progress, done, blocked" },
|
|
1080
|
+
{ flag: "--priority <priority>", description: "low, medium, high, critical" },
|
|
1081
|
+
{ flag: "--points <n>", description: "Story points (0-10)", type: "number" }
|
|
1082
|
+
],
|
|
1083
|
+
examples: ["forloop story update --id 123 --status in_progress"],
|
|
1084
|
+
async run(_config, flags) {
|
|
1085
|
+
const resolved = await getClient();
|
|
1086
|
+
if (!resolved) return;
|
|
1087
|
+
const { client } = resolved;
|
|
1088
|
+
const storyId = flags.id;
|
|
1089
|
+
if (!storyId) {
|
|
1090
|
+
console.error("Error: --id is required");
|
|
1091
|
+
process.exit(1);
|
|
1092
|
+
}
|
|
1093
|
+
const data = {};
|
|
1094
|
+
if (flags.title !== void 0) data.title = flags.title;
|
|
1095
|
+
if (flags.description !== void 0) data.description = flags.description;
|
|
1096
|
+
if (flags.status !== void 0) data.status = flags.status;
|
|
1097
|
+
if (flags.priority !== void 0) data.priority = flags.priority;
|
|
1098
|
+
if (flags.points !== void 0) data.points = flags.points;
|
|
1099
|
+
if (Object.keys(data).length === 0) {
|
|
1100
|
+
console.error("Error: at least one field to update is required");
|
|
1101
|
+
process.exit(1);
|
|
1102
|
+
}
|
|
1103
|
+
const story = await client.updateStory(storyId, data);
|
|
1104
|
+
console.log(`Story #${story.id} updated successfully.`);
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
function createStoryDeleteCommand() {
|
|
1109
|
+
return defineCommand({
|
|
1110
|
+
name: "story delete",
|
|
1111
|
+
description: "Delete a story",
|
|
1112
|
+
usage: "forloop story delete --id <number> --confirm",
|
|
1113
|
+
options: [
|
|
1114
|
+
{ flag: "--id <number>", description: "Story ID", required: true, type: "number" },
|
|
1115
|
+
{ flag: "--confirm", description: "Confirm deletion" }
|
|
1116
|
+
],
|
|
1117
|
+
examples: ["forloop story delete --id 123 --confirm"],
|
|
1118
|
+
async run(_config, flags) {
|
|
1119
|
+
if (!flags.confirm) {
|
|
1120
|
+
console.error("Error: --confirm is required to permanently delete a story.");
|
|
1121
|
+
process.exit(1);
|
|
1122
|
+
}
|
|
1123
|
+
const resolved = await getClient();
|
|
1124
|
+
if (!resolved) return;
|
|
1125
|
+
const { client } = resolved;
|
|
1126
|
+
await client.deleteStory(flags.id);
|
|
1127
|
+
console.log(`Story #${flags.id} deleted successfully.`);
|
|
1128
|
+
}
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// src/commands/sprint.ts
|
|
1133
|
+
function createSprintCommands() {
|
|
1134
|
+
return {
|
|
1135
|
+
"sprint get": createSprintGetCommand(),
|
|
1136
|
+
"sprint list": createSprintListCommand(),
|
|
1137
|
+
"sprint create": createSprintCreateCommand(),
|
|
1138
|
+
"sprint update": createSprintUpdateCommand(),
|
|
1139
|
+
"sprint delete": createSprintDeleteCommand()
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
async function getClient2() {
|
|
1143
|
+
const config = getConfig();
|
|
1144
|
+
const token = await getToken();
|
|
1145
|
+
if (!token) {
|
|
1146
|
+
console.error("Error: No API token configured. Run: forloop auth login --api-key <token>");
|
|
1147
|
+
process.exit(1);
|
|
1148
|
+
}
|
|
1149
|
+
const client = new ForLoopAPIClient({ token, baseUrl: config.apiUrl });
|
|
1150
|
+
return { client };
|
|
1151
|
+
}
|
|
1152
|
+
function formatDate(dateString) {
|
|
1153
|
+
return new Date(dateString).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
|
1154
|
+
}
|
|
1155
|
+
function createSprintGetCommand() {
|
|
1156
|
+
return defineCommand({
|
|
1157
|
+
name: "sprint get",
|
|
1158
|
+
description: "Get sprint details including stories and files",
|
|
1159
|
+
usage: "forloop sprint get --id <number>",
|
|
1160
|
+
options: [
|
|
1161
|
+
{ flag: "--id <number>", description: "Sprint ID (auto-detected if not provided)", type: "number" },
|
|
1162
|
+
{ flag: "--no-stories", description: "Exclude stories from response" },
|
|
1163
|
+
{ flag: "--no-files", description: "Exclude files from response" }
|
|
1164
|
+
],
|
|
1165
|
+
examples: ["forloop sprint get --id 456", "forloop sprint get --id 456 --no-files"],
|
|
1166
|
+
async run(_config, flags) {
|
|
1167
|
+
const resolved = await getClient2();
|
|
1168
|
+
if (!resolved) return;
|
|
1169
|
+
const { client } = resolved;
|
|
1170
|
+
const resolution = await resolveSprintId(flags.id);
|
|
1171
|
+
if (!resolution.sprintId) {
|
|
1172
|
+
console.error("Error: No sprint ID resolved. Use --id, set FORLOOP_SPRINT_ID, or use a sprint-XXX branch.");
|
|
1173
|
+
process.exit(1);
|
|
1174
|
+
}
|
|
1175
|
+
const sprint = await client.getSprint(resolution.sprintId, {
|
|
1176
|
+
includeStories: !flags.noStories,
|
|
1177
|
+
includeFiles: !flags.noFiles
|
|
1178
|
+
});
|
|
1179
|
+
const lines = [
|
|
1180
|
+
`Sprint #${sprint.id}: ${sprint.title}`,
|
|
1181
|
+
`Status: ${sprint.status}`,
|
|
1182
|
+
`Period: ${formatDate(sprint.startDate)} - ${formatDate(sprint.endDate)}`,
|
|
1183
|
+
`Stories: ${sprint.stories?.length || 0}`
|
|
1184
|
+
];
|
|
1185
|
+
if (sprint.stories?.length) {
|
|
1186
|
+
lines.push("", "Stories:", "");
|
|
1187
|
+
for (const story of sprint.stories) {
|
|
1188
|
+
const icons = { todo: "[ ]", in_progress: "[>]", done: "[x]", blocked: "[!]" };
|
|
1189
|
+
lines.push(` ${icons[story.status] || "[?]"} #${story.id} ${story.title} (${story.status})`);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
console.log(lines.join("\n"));
|
|
1193
|
+
}
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
function createSprintListCommand() {
|
|
1197
|
+
return defineCommand({
|
|
1198
|
+
name: "sprint list",
|
|
1199
|
+
description: "List all accessible sprints",
|
|
1200
|
+
usage: "forloop sprint list [--org-id <id>] [--include-system-org]",
|
|
1201
|
+
options: [
|
|
1202
|
+
{ flag: "--org-id <id>", description: "Filter by organization ID", type: "number" },
|
|
1203
|
+
{ flag: "--include-system-org", description: "Include system organization sprints" }
|
|
1204
|
+
],
|
|
1205
|
+
examples: ["forloop sprint list", "forloop sprint list --org-id 1"],
|
|
1206
|
+
async run(_config, flags) {
|
|
1207
|
+
const resolved = await getClient2();
|
|
1208
|
+
if (!resolved) return;
|
|
1209
|
+
const { client } = resolved;
|
|
1210
|
+
const params = {};
|
|
1211
|
+
if (flags.orgId) params.organizationId = String(flags.orgId);
|
|
1212
|
+
const sprints = await client.listSprints(params);
|
|
1213
|
+
if (!sprints.length) {
|
|
1214
|
+
console.log("No sprints found.");
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
const lines = ["Sprints:", ""];
|
|
1218
|
+
for (const sprint of sprints) {
|
|
1219
|
+
const orgInfo = sprint.organizationName ? ` (${sprint.organizationName})` : "";
|
|
1220
|
+
lines.push(` #${sprint.id} ${sprint.title}${orgInfo} - ${sprint.status}`);
|
|
1221
|
+
}
|
|
1222
|
+
console.log(lines.join("\n"));
|
|
1223
|
+
}
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
function createSprintCreateCommand() {
|
|
1227
|
+
return defineCommand({
|
|
1228
|
+
name: "sprint create",
|
|
1229
|
+
description: "Create a new sprint",
|
|
1230
|
+
usage: "forloop sprint create --title <text> --start-date <YYYY-MM-DD> --end-date <YYYY-MM-DD> [flags]",
|
|
1231
|
+
options: [
|
|
1232
|
+
{ flag: "--title <text>", description: "Sprint title", required: true },
|
|
1233
|
+
{ flag: "--start-date <date>", description: "Start date (YYYY-MM-DD)", required: true },
|
|
1234
|
+
{ flag: "--end-date <date>", description: "End date (YYYY-MM-DD)", required: true },
|
|
1235
|
+
{ flag: "--description <text>", description: "Sprint description" },
|
|
1236
|
+
{ flag: "--private", description: "Make sprint private" },
|
|
1237
|
+
{ flag: "--org-id <id>", description: "Organization ID", type: "number" }
|
|
1238
|
+
],
|
|
1239
|
+
examples: ['forloop sprint create --title "Sprint 15" --start-date 2026-06-09 --end-date 2026-06-23'],
|
|
1240
|
+
async run(_config, flags) {
|
|
1241
|
+
const resolved = await getClient2();
|
|
1242
|
+
if (!resolved) return;
|
|
1243
|
+
const { client } = resolved;
|
|
1244
|
+
if (!flags.title || !flags.startDate || !flags.endDate) {
|
|
1245
|
+
console.error("Error: --title, --start-date, and --end-date are required");
|
|
1246
|
+
process.exit(1);
|
|
1247
|
+
}
|
|
1248
|
+
const sprint = await client.createSprint({
|
|
1249
|
+
title: flags.title,
|
|
1250
|
+
description: flags.description,
|
|
1251
|
+
startDate: flags.startDate,
|
|
1252
|
+
endDate: flags.endDate,
|
|
1253
|
+
isPrivate: flags.private === true,
|
|
1254
|
+
organizationId: flags.orgId
|
|
1255
|
+
});
|
|
1256
|
+
console.log(`Sprint created: #${sprint.id} - ${sprint.title}`);
|
|
1257
|
+
console.log(`Period: ${formatDate(sprint.startDate)} - ${formatDate(sprint.endDate)}`);
|
|
1258
|
+
}
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
function createSprintUpdateCommand() {
|
|
1262
|
+
return defineCommand({
|
|
1263
|
+
name: "sprint update",
|
|
1264
|
+
description: "Update sprint details",
|
|
1265
|
+
usage: "forloop sprint update --id <number> [flags]",
|
|
1266
|
+
options: [
|
|
1267
|
+
{ flag: "--id <number>", description: "Sprint ID", required: true, type: "number" },
|
|
1268
|
+
{ flag: "--title <text>", description: "New title" },
|
|
1269
|
+
{ flag: "--description <text>", description: "New description" },
|
|
1270
|
+
{ flag: "--start-date <date>", description: "New start date" },
|
|
1271
|
+
{ flag: "--end-date <date>", description: "New end date" },
|
|
1272
|
+
{ flag: "--private", description: "Set visibility to private" }
|
|
1273
|
+
],
|
|
1274
|
+
examples: ['forloop sprint update --id 456 --title "Updated Sprint"'],
|
|
1275
|
+
async run(_config, flags) {
|
|
1276
|
+
const resolved = await getClient2();
|
|
1277
|
+
if (!resolved) return;
|
|
1278
|
+
const { client } = resolved;
|
|
1279
|
+
const updateData = {};
|
|
1280
|
+
if (flags.title !== void 0) updateData.title = flags.title;
|
|
1281
|
+
if (flags.description !== void 0) updateData.description = flags.description;
|
|
1282
|
+
if (flags.startDate !== void 0) updateData.startDate = flags.startDate;
|
|
1283
|
+
if (flags.endDate !== void 0) updateData.endDate = flags.endDate;
|
|
1284
|
+
if (flags.private !== void 0) updateData.isPrivate = true;
|
|
1285
|
+
if (Object.keys(updateData).length === 0) {
|
|
1286
|
+
console.error("Error: at least one field to update is required");
|
|
1287
|
+
process.exit(1);
|
|
1288
|
+
}
|
|
1289
|
+
const sprint = await client.updateSprint(flags.id, updateData);
|
|
1290
|
+
console.log(`Sprint #${sprint.id} updated successfully.`);
|
|
1291
|
+
}
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
function createSprintDeleteCommand() {
|
|
1295
|
+
return defineCommand({
|
|
1296
|
+
name: "sprint delete",
|
|
1297
|
+
description: "Delete a sprint permanently",
|
|
1298
|
+
usage: "forloop sprint delete --id <number> --confirm",
|
|
1299
|
+
options: [
|
|
1300
|
+
{ flag: "--id <number>", description: "Sprint ID", required: true, type: "number" },
|
|
1301
|
+
{ flag: "--confirm", description: "Confirm deletion" }
|
|
1302
|
+
],
|
|
1303
|
+
examples: ["forloop sprint delete --id 456 --confirm"],
|
|
1304
|
+
async run(_config, flags) {
|
|
1305
|
+
if (!flags.confirm) {
|
|
1306
|
+
console.error("Error: --confirm is required to permanently delete a sprint.");
|
|
1307
|
+
process.exit(1);
|
|
1308
|
+
}
|
|
1309
|
+
const resolved = await getClient2();
|
|
1310
|
+
if (!resolved) return;
|
|
1311
|
+
const { client } = resolved;
|
|
1312
|
+
await client.deleteSprint(flags.id);
|
|
1313
|
+
console.log(`Sprint #${flags.id} deleted successfully.`);
|
|
1314
|
+
}
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// src/commands/org.ts
|
|
1319
|
+
function createOrgCommands() {
|
|
1320
|
+
return {
|
|
1321
|
+
"org list": createOrgListCommand(),
|
|
1322
|
+
"org get": createOrgGetCommand(),
|
|
1323
|
+
"org create": createOrgCreateCommand(),
|
|
1324
|
+
"org update": createOrgUpdateCommand()
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
async function getClient3() {
|
|
1328
|
+
const config = getConfig();
|
|
1329
|
+
const token = await getToken();
|
|
1330
|
+
if (!token) {
|
|
1331
|
+
console.error("Error: No API token configured. Run: forloop auth login --api-key <token>");
|
|
1332
|
+
process.exit(1);
|
|
1333
|
+
}
|
|
1334
|
+
return { client: new ForLoopAPIClient({ token, baseUrl: config.apiUrl }) };
|
|
1335
|
+
}
|
|
1336
|
+
function createOrgListCommand() {
|
|
1337
|
+
return defineCommand({
|
|
1338
|
+
name: "org list",
|
|
1339
|
+
description: "List organizations you belong to",
|
|
1340
|
+
usage: "forloop org list [--owned-only]",
|
|
1341
|
+
options: [
|
|
1342
|
+
{ flag: "--owned-only", description: "List only organizations you own" }
|
|
1343
|
+
],
|
|
1344
|
+
examples: ["forloop org list", "forloop org list --owned-only"],
|
|
1345
|
+
async run(_config, flags) {
|
|
1346
|
+
const resolved = await getClient3();
|
|
1347
|
+
if (!resolved) return;
|
|
1348
|
+
const { client } = resolved;
|
|
1349
|
+
const orgs = flags.ownedOnly ? await client.getOwnedOrganizations() : await client.listOrganizations();
|
|
1350
|
+
if (!orgs.length) {
|
|
1351
|
+
console.log("No organizations found.");
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
const lines = ["Organizations:", ""];
|
|
1355
|
+
for (const org of orgs) {
|
|
1356
|
+
const role = org.members?.[0]?.role || "member";
|
|
1357
|
+
lines.push(` #${org.id} ${org.name} (${role})`);
|
|
1358
|
+
}
|
|
1359
|
+
console.log(lines.join("\n"));
|
|
1360
|
+
}
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
function createOrgGetCommand() {
|
|
1364
|
+
return defineCommand({
|
|
1365
|
+
name: "org get",
|
|
1366
|
+
description: "Get organization details",
|
|
1367
|
+
usage: "forloop org get --id <number>",
|
|
1368
|
+
options: [
|
|
1369
|
+
{ flag: "--id <number>", description: "Organization ID", required: true, type: "number" }
|
|
1370
|
+
],
|
|
1371
|
+
examples: ["forloop org get --id 1"],
|
|
1372
|
+
async run(_config, flags) {
|
|
1373
|
+
if (!flags.id) {
|
|
1374
|
+
console.error("Error: --id is required");
|
|
1375
|
+
process.exit(1);
|
|
1376
|
+
}
|
|
1377
|
+
const resolved = await getClient3();
|
|
1378
|
+
if (!resolved) return;
|
|
1379
|
+
const { client } = resolved;
|
|
1380
|
+
const org = await client.getOrganization(flags.id);
|
|
1381
|
+
console.log(`Organization #${org.id}: ${org.name}`);
|
|
1382
|
+
console.log(`Type: ${org.type || "user"}`);
|
|
1383
|
+
if (org.description) console.log(`Description: ${org.description}`);
|
|
1384
|
+
}
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
function createOrgCreateCommand() {
|
|
1388
|
+
return defineCommand({
|
|
1389
|
+
name: "org create",
|
|
1390
|
+
description: "Create a new organization (requires Team/Enterprise tier)",
|
|
1391
|
+
usage: "forloop org create --name <text> [--description <text>]",
|
|
1392
|
+
options: [
|
|
1393
|
+
{ flag: "--name <text>", description: "Organization name", required: true },
|
|
1394
|
+
{ flag: "--description <text>", description: "Organization description" }
|
|
1395
|
+
],
|
|
1396
|
+
examples: ['forloop org create --name "My Team" --description "Engineering team"'],
|
|
1397
|
+
async run(_config, flags) {
|
|
1398
|
+
if (!flags.name) {
|
|
1399
|
+
console.error("Error: --name is required");
|
|
1400
|
+
process.exit(1);
|
|
1401
|
+
}
|
|
1402
|
+
const resolved = await getClient3();
|
|
1403
|
+
if (!resolved) return;
|
|
1404
|
+
const { client } = resolved;
|
|
1405
|
+
const org = await client.createOrganization({
|
|
1406
|
+
name: flags.name,
|
|
1407
|
+
description: flags.description
|
|
1408
|
+
});
|
|
1409
|
+
console.log(`Organization created: #${org.id} - ${org.name}`);
|
|
1410
|
+
}
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
function createOrgUpdateCommand() {
|
|
1414
|
+
return defineCommand({
|
|
1415
|
+
name: "org update",
|
|
1416
|
+
description: "Update organization details",
|
|
1417
|
+
usage: "forloop org update --id <number> [--name <text>] [--description <text>]",
|
|
1418
|
+
options: [
|
|
1419
|
+
{ flag: "--id <number>", description: "Organization ID", required: true, type: "number" },
|
|
1420
|
+
{ flag: "--name <text>", description: "New name" },
|
|
1421
|
+
{ flag: "--description <text>", description: "New description" }
|
|
1422
|
+
],
|
|
1423
|
+
examples: ['forloop org update --id 1 --name "New Name"'],
|
|
1424
|
+
async run(_config, flags) {
|
|
1425
|
+
if (!flags.id) {
|
|
1426
|
+
console.error("Error: --id is required");
|
|
1427
|
+
process.exit(1);
|
|
1428
|
+
}
|
|
1429
|
+
const resolved = await getClient3();
|
|
1430
|
+
if (!resolved) return;
|
|
1431
|
+
const { client } = resolved;
|
|
1432
|
+
const org = await client.updateOrganization(flags.id, {
|
|
1433
|
+
name: flags.name,
|
|
1434
|
+
description: flags.description
|
|
1435
|
+
});
|
|
1436
|
+
console.log(`Organization #${org.id} updated successfully.`);
|
|
1437
|
+
}
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// src/commands/template.ts
|
|
1442
|
+
function createTemplateCommands() {
|
|
1443
|
+
return {
|
|
1444
|
+
"template list": createTemplateListCommand()
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
function createTemplateListCommand() {
|
|
1448
|
+
return defineCommand({
|
|
1449
|
+
name: "template list",
|
|
1450
|
+
description: "List available story templates",
|
|
1451
|
+
usage: "forloop template list",
|
|
1452
|
+
examples: ["forloop template list"],
|
|
1453
|
+
async run(_config) {
|
|
1454
|
+
const config = getConfig();
|
|
1455
|
+
const token = await getToken();
|
|
1456
|
+
if (!token) {
|
|
1457
|
+
console.error("Error: No API token configured.");
|
|
1458
|
+
process.exit(1);
|
|
1459
|
+
}
|
|
1460
|
+
const client = new ForLoopAPIClient({ token, baseUrl: config.apiUrl });
|
|
1461
|
+
const templates = await client.listTemplates();
|
|
1462
|
+
const active = templates.filter((t) => !t.deletedAt);
|
|
1463
|
+
if (!active.length) {
|
|
1464
|
+
console.log("No templates found.");
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
const lines = ["Available Templates:", ""];
|
|
1468
|
+
for (const t of active) {
|
|
1469
|
+
const fields = JSON.parse(t.fields);
|
|
1470
|
+
const fieldNames = fields.map((f) => f.label).join(", ");
|
|
1471
|
+
lines.push(` ${t.name} (slug: ${t.slug})`);
|
|
1472
|
+
lines.push(` ${t.description}`);
|
|
1473
|
+
lines.push(` Fields: ${fieldNames}`);
|
|
1474
|
+
lines.push("");
|
|
1475
|
+
}
|
|
1476
|
+
console.log(lines.join("\n"));
|
|
1477
|
+
}
|
|
1478
|
+
});
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// src/commands/agent.ts
|
|
1482
|
+
function createAgentCommands() {
|
|
1483
|
+
return {
|
|
1484
|
+
"agent history": createAgentHistoryCommand(),
|
|
1485
|
+
"agent developer-sprint": createDeveloperSprintCommand(),
|
|
1486
|
+
"agent developer-status": createDeveloperStatusCommand()
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
async function getClient4() {
|
|
1490
|
+
const config = getConfig();
|
|
1491
|
+
const token = await getToken();
|
|
1492
|
+
if (!token) {
|
|
1493
|
+
console.error("Error: No API token configured.");
|
|
1494
|
+
process.exit(1);
|
|
1495
|
+
}
|
|
1496
|
+
return { client: new ForLoopAPIClient({ token, baseUrl: config.apiUrl }) };
|
|
1497
|
+
}
|
|
1498
|
+
function createAgentHistoryCommand() {
|
|
1499
|
+
return defineCommand({
|
|
1500
|
+
name: "agent history",
|
|
1501
|
+
description: "View conversation history with AI agents from opencode sessions",
|
|
1502
|
+
usage: "forloop agent history [--sprint <id>] [--limit <n>]",
|
|
1503
|
+
options: [
|
|
1504
|
+
{ flag: "--sprint <id>", description: "Sprint ID", type: "number" },
|
|
1505
|
+
{ flag: "--limit <n>", description: "Number of messages (max 200, default 50)", type: "number" }
|
|
1506
|
+
],
|
|
1507
|
+
examples: ["forloop agent history --sprint 1 --limit 20"],
|
|
1508
|
+
async run(_config, flags) {
|
|
1509
|
+
const resolved = await getClient4();
|
|
1510
|
+
if (!resolved) return;
|
|
1511
|
+
const { client } = resolved;
|
|
1512
|
+
const resolution = await resolveSprintId(flags.sprint);
|
|
1513
|
+
const sprintId = resolution.sprintId || flags.sprint;
|
|
1514
|
+
const history = await client.getConversationHistory({
|
|
1515
|
+
sprintId,
|
|
1516
|
+
limit: flags.limit || 50
|
|
1517
|
+
});
|
|
1518
|
+
const messages = history.messages || [];
|
|
1519
|
+
if (!messages.length) {
|
|
1520
|
+
console.log("No conversation history found.");
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
console.log(`Conversation History (${history.total || messages.length} total):`);
|
|
1524
|
+
for (const msg of messages) {
|
|
1525
|
+
const preview = (msg.userMessage || "").substring(0, 80);
|
|
1526
|
+
console.log(` ${preview}${preview.length >= 80 ? "..." : ""}`);
|
|
1527
|
+
if (msg.agentResponse) {
|
|
1528
|
+
const resp = msg.agentResponse.substring(0, 100);
|
|
1529
|
+
console.log(` -> ${resp}${msg.agentResponse.length > 100 ? "..." : ""}`);
|
|
1530
|
+
}
|
|
1531
|
+
console.log("");
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
});
|
|
1535
|
+
}
|
|
1536
|
+
function createDeveloperSprintCommand() {
|
|
1537
|
+
return defineCommand({
|
|
1538
|
+
name: "agent developer-sprint",
|
|
1539
|
+
description: "Trigger the developer agent to implement sprint stories",
|
|
1540
|
+
usage: "forloop agent developer-sprint --sprint <id> [--message <text>]",
|
|
1541
|
+
options: [
|
|
1542
|
+
{ flag: "--sprint <id>", description: "Sprint ID", required: true, type: "number" },
|
|
1543
|
+
{ flag: "--message <text>", description: "Instructions for the developer agent" }
|
|
1544
|
+
],
|
|
1545
|
+
examples: ['forloop agent developer-sprint --sprint 1 --message "Implement all planned tasks"'],
|
|
1546
|
+
async run(_config, flags) {
|
|
1547
|
+
if (!flags.sprint) {
|
|
1548
|
+
console.error("Error: --sprint is required");
|
|
1549
|
+
process.exit(1);
|
|
1550
|
+
}
|
|
1551
|
+
const resolved = await getClient4();
|
|
1552
|
+
if (!resolved) return;
|
|
1553
|
+
const { client } = resolved;
|
|
1554
|
+
const response = await client.chatWithAI({
|
|
1555
|
+
sprintId: flags.sprint,
|
|
1556
|
+
message: flags.message || "Start implementing the sprint stories assigned to the developer agent",
|
|
1557
|
+
selectedAgentKey: "forLoopTaskSupervisor",
|
|
1558
|
+
type: "developer.sprint",
|
|
1559
|
+
metadata: { channel: "developer_sprint" }
|
|
1560
|
+
});
|
|
1561
|
+
const taskId = response.taskId || response.id || "unknown";
|
|
1562
|
+
console.log(`Developer sprint triggered:`);
|
|
1563
|
+
console.log(` Task ID: ${taskId}`);
|
|
1564
|
+
console.log(` Sprint: #${flags.sprint}`);
|
|
1565
|
+
console.log(` Agent: forLoopTaskSupervisor`);
|
|
1566
|
+
console.log(`Check dashboard for story progress.`);
|
|
1567
|
+
}
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
function createDeveloperStatusCommand() {
|
|
1571
|
+
return defineCommand({
|
|
1572
|
+
name: "agent developer-status",
|
|
1573
|
+
description: "Check status of a running developer task",
|
|
1574
|
+
usage: "forloop agent developer-status [--sprint <id>]",
|
|
1575
|
+
options: [
|
|
1576
|
+
{ flag: "--sprint <id>", description: "Sprint ID", type: "number" }
|
|
1577
|
+
],
|
|
1578
|
+
examples: ["forloop agent developer-status --sprint 1"],
|
|
1579
|
+
async run(_config, flags) {
|
|
1580
|
+
const resolved = await getClient4();
|
|
1581
|
+
if (!resolved) return;
|
|
1582
|
+
const { client } = resolved;
|
|
1583
|
+
const resolution = await resolveSprintId(flags.sprint);
|
|
1584
|
+
if (!resolution.sprintId) {
|
|
1585
|
+
console.error("Error: No sprint ID resolved.");
|
|
1586
|
+
process.exit(1);
|
|
1587
|
+
}
|
|
1588
|
+
const status = await client.getDeveloperStatus(resolution.sprintId);
|
|
1589
|
+
const sprint = status.sprint || {};
|
|
1590
|
+
if (!status.hasActiveTask) {
|
|
1591
|
+
console.log(`No active developer task.`);
|
|
1592
|
+
console.log(`Sprint #${resolution.sprintId}: ${sprint.storiesDone || 0}/${sprint.storiesTotal || 0} stories done.`);
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
const exec3 = status.execution || {};
|
|
1596
|
+
const task = status.task || {};
|
|
1597
|
+
console.log(`Developer Task: ${exec3.status}`);
|
|
1598
|
+
console.log(`Sprint: #${resolution.sprintId}`);
|
|
1599
|
+
if (exec3.startedAt) console.log(`Started: ${new Date(exec3.startedAt).toLocaleString()}`);
|
|
1600
|
+
if (task.branchName) console.log(`Branch: ${task.branchName}`);
|
|
1601
|
+
if (task.errorMessage) console.log(`Error: ${task.errorMessage}`);
|
|
1602
|
+
console.log(`Progress: ${sprint.storiesDone || 0}/${sprint.storiesTotal || 0} done, ${sprint.storiesInProgress || 0} in progress.`);
|
|
1603
|
+
}
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
// src/commands/user.ts
|
|
1608
|
+
function createUserCommands() {
|
|
1609
|
+
return {
|
|
1610
|
+
"user profile": createUserProfileCommand(),
|
|
1611
|
+
"user quotas": createUserQuotasCommand(),
|
|
1612
|
+
"org quotas": createOrgQuotasCommand()
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
async function getClient5() {
|
|
1616
|
+
const config = getConfig();
|
|
1617
|
+
const token = await getToken();
|
|
1618
|
+
if (!token) {
|
|
1619
|
+
console.error("Error: No API token configured.");
|
|
1620
|
+
process.exit(1);
|
|
1621
|
+
}
|
|
1622
|
+
return { client: new ForLoopAPIClient({ token, baseUrl: config.apiUrl }) };
|
|
1623
|
+
}
|
|
1624
|
+
function createUserProfileCommand() {
|
|
1625
|
+
return defineCommand({
|
|
1626
|
+
name: "user profile",
|
|
1627
|
+
description: "Get current user profile",
|
|
1628
|
+
usage: "forloop user profile",
|
|
1629
|
+
examples: ["forloop user profile"],
|
|
1630
|
+
async run(_config) {
|
|
1631
|
+
const resolved = await getClient5();
|
|
1632
|
+
if (!resolved) return;
|
|
1633
|
+
const { client } = resolved;
|
|
1634
|
+
const profile = await client.getUserProfile();
|
|
1635
|
+
console.log(`User: ${profile.name || profile.email || "Unknown"}`);
|
|
1636
|
+
console.log(`Email: ${profile.email}`);
|
|
1637
|
+
if (profile.tier) console.log(`Tier: ${profile.tier}`);
|
|
1638
|
+
}
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
function createUserQuotasCommand() {
|
|
1642
|
+
return defineCommand({
|
|
1643
|
+
name: "user quotas",
|
|
1644
|
+
description: "Check user quota limits",
|
|
1645
|
+
usage: "forloop user quotas",
|
|
1646
|
+
examples: ["forloop user quotas"],
|
|
1647
|
+
async run(_config) {
|
|
1648
|
+
const resolved = await getClient5();
|
|
1649
|
+
if (!resolved) return;
|
|
1650
|
+
const { client } = resolved;
|
|
1651
|
+
const quotas = await client.getUserQuotas();
|
|
1652
|
+
console.log("User Quotas:");
|
|
1653
|
+
console.log(JSON.stringify(quotas, null, 2));
|
|
1654
|
+
}
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
1657
|
+
function createOrgQuotasCommand() {
|
|
1658
|
+
return defineCommand({
|
|
1659
|
+
name: "org quotas",
|
|
1660
|
+
description: "Get organization quota usage",
|
|
1661
|
+
usage: "forloop org quotas --org-id <id>",
|
|
1662
|
+
options: [
|
|
1663
|
+
{ flag: "--org-id <id>", description: "Organization ID", required: true, type: "number" }
|
|
1664
|
+
],
|
|
1665
|
+
examples: ["forloop org quotas --org-id 1"],
|
|
1666
|
+
async run(_config, flags) {
|
|
1667
|
+
if (!flags.orgId) {
|
|
1668
|
+
console.error("Error: --org-id is required");
|
|
1669
|
+
process.exit(1);
|
|
1670
|
+
}
|
|
1671
|
+
const resolved = await getClient5();
|
|
1672
|
+
if (!resolved) return;
|
|
1673
|
+
const { client } = resolved;
|
|
1674
|
+
const quotas = await client.getOrganizationQuotas(flags.orgId);
|
|
1675
|
+
console.log("Organization Quotas:");
|
|
1676
|
+
console.log(JSON.stringify(quotas, null, 2));
|
|
1677
|
+
}
|
|
1678
|
+
});
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
// src/commands/file.ts
|
|
1682
|
+
import * as fs4 from "fs";
|
|
1683
|
+
import * as path4 from "path";
|
|
1684
|
+
function createFileCommands() {
|
|
1685
|
+
return {
|
|
1686
|
+
"file upload": createFileUploadCommand(),
|
|
1687
|
+
"file list": createFileListCommand(),
|
|
1688
|
+
"file delete": createFileDeleteCommand(),
|
|
1689
|
+
"file download": createFileDownloadCommand()
|
|
1690
|
+
};
|
|
1691
|
+
}
|
|
1692
|
+
async function getClient6() {
|
|
1693
|
+
const config = getConfig();
|
|
1694
|
+
const token = await getToken();
|
|
1695
|
+
if (!token) {
|
|
1696
|
+
console.error("Error: No API token configured.");
|
|
1697
|
+
process.exit(1);
|
|
1698
|
+
}
|
|
1699
|
+
return { client: new ForLoopAPIClient({ token, baseUrl: config.apiUrl }) };
|
|
1700
|
+
}
|
|
1701
|
+
var mimeTypes = {
|
|
1702
|
+
".png": "image/png",
|
|
1703
|
+
".jpg": "image/jpeg",
|
|
1704
|
+
".jpeg": "image/jpeg",
|
|
1705
|
+
".gif": "image/gif",
|
|
1706
|
+
".pdf": "application/pdf",
|
|
1707
|
+
".txt": "text/plain",
|
|
1708
|
+
".md": "text/markdown",
|
|
1709
|
+
".json": "application/json",
|
|
1710
|
+
".csv": "text/csv",
|
|
1711
|
+
".mp4": "video/mp4",
|
|
1712
|
+
".mp3": "audio/mpeg"
|
|
1713
|
+
};
|
|
1714
|
+
function formatSize(bytes) {
|
|
1715
|
+
if (bytes === 0) return "0 Bytes";
|
|
1716
|
+
const k = 1024;
|
|
1717
|
+
const sizes = ["Bytes", "KB", "MB", "GB"];
|
|
1718
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
1719
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
|
1720
|
+
}
|
|
1721
|
+
function createFileUploadCommand() {
|
|
1722
|
+
return defineCommand({
|
|
1723
|
+
name: "file upload",
|
|
1724
|
+
description: "Upload a file to a sprint via presigned S3 URL",
|
|
1725
|
+
usage: "forloop file upload --path <path> --sprint <id> [--description <text>] [--folder <text>]",
|
|
1726
|
+
options: [
|
|
1727
|
+
{ flag: "--path <path>", description: "Local file path", required: true },
|
|
1728
|
+
{ flag: "--sprint <id>", description: "Target sprint ID", required: true, type: "number" },
|
|
1729
|
+
{ flag: "--description <text>", description: "File description" },
|
|
1730
|
+
{ flag: "--folder <text>", description: "S3 folder within sprint" }
|
|
1731
|
+
],
|
|
1732
|
+
examples: ["forloop file upload --path ./report.pdf --sprint 1 --folder docs"],
|
|
1733
|
+
async run(_config, flags) {
|
|
1734
|
+
const resolved = await getClient6();
|
|
1735
|
+
if (!resolved) return;
|
|
1736
|
+
const { client } = resolved;
|
|
1737
|
+
const filePath = flags.path;
|
|
1738
|
+
if (!fs4.existsSync(filePath)) {
|
|
1739
|
+
console.error(`Error: File not found: ${filePath}`);
|
|
1740
|
+
process.exit(1);
|
|
1741
|
+
}
|
|
1742
|
+
const fileContent = fs4.readFileSync(filePath);
|
|
1743
|
+
const fileName = path4.basename(filePath);
|
|
1744
|
+
const ext = path4.extname(fileName);
|
|
1745
|
+
const contentType = mimeTypes[ext] || "application/octet-stream";
|
|
1746
|
+
const presign = await client.createPresignedUpload({
|
|
1747
|
+
sprintId: flags.sprint,
|
|
1748
|
+
contentType,
|
|
1749
|
+
originalName: fileName,
|
|
1750
|
+
folder: flags.folder
|
|
1751
|
+
});
|
|
1752
|
+
const uploadResponse = await fetch(presign.uploadUrl, {
|
|
1753
|
+
method: "PUT",
|
|
1754
|
+
headers: { "Content-Type": contentType, "Content-Length": String(fileContent.length) },
|
|
1755
|
+
body: fileContent
|
|
1756
|
+
});
|
|
1757
|
+
if (!uploadResponse.ok) {
|
|
1758
|
+
console.error(`Error: Upload failed (${uploadResponse.status})`);
|
|
1759
|
+
process.exit(1);
|
|
1760
|
+
}
|
|
1761
|
+
const complete = await client.completeUpload({
|
|
1762
|
+
sprintId: flags.sprint,
|
|
1763
|
+
fileName: presign.fileName,
|
|
1764
|
+
originalName: fileName,
|
|
1765
|
+
fileType: contentType,
|
|
1766
|
+
size: fileContent.length,
|
|
1767
|
+
folder: flags.folder
|
|
1768
|
+
});
|
|
1769
|
+
console.log(`File uploaded: ${fileName} (${formatSize(fileContent.length)})`);
|
|
1770
|
+
console.log(`URL: ${complete.url}`);
|
|
1771
|
+
}
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1774
|
+
function createFileListCommand() {
|
|
1775
|
+
return defineCommand({
|
|
1776
|
+
name: "file list",
|
|
1777
|
+
description: "List files attached to a sprint",
|
|
1778
|
+
usage: "forloop file list --sprint <id>",
|
|
1779
|
+
options: [
|
|
1780
|
+
{ flag: "--sprint <id>", description: "Sprint ID", required: true, type: "number" }
|
|
1781
|
+
],
|
|
1782
|
+
examples: ["forloop file list --sprint 1"],
|
|
1783
|
+
async run(_config, flags) {
|
|
1784
|
+
const resolved = await getClient6();
|
|
1785
|
+
if (!resolved) return;
|
|
1786
|
+
const { client } = resolved;
|
|
1787
|
+
const files = await client.getSprintFiles(flags.sprint);
|
|
1788
|
+
if (!files.length) {
|
|
1789
|
+
console.log("No files found.");
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
const lines = [`Files in Sprint #${flags.sprint}:`, ""];
|
|
1793
|
+
for (const file of files) {
|
|
1794
|
+
lines.push(` ${file.originalName || file.filename}`);
|
|
1795
|
+
lines.push(` Size: ${formatSize(file.size)} | Type: ${file.fileType}`);
|
|
1796
|
+
lines.push(` URL: ${file.url}`);
|
|
1797
|
+
lines.push("");
|
|
1798
|
+
}
|
|
1799
|
+
console.log(lines.join("\n"));
|
|
1800
|
+
}
|
|
1801
|
+
});
|
|
1802
|
+
}
|
|
1803
|
+
function createFileDeleteCommand() {
|
|
1804
|
+
return defineCommand({
|
|
1805
|
+
name: "file delete",
|
|
1806
|
+
description: "Delete a file from a sprint permanently",
|
|
1807
|
+
usage: "forloop file delete --id <number> --confirm",
|
|
1808
|
+
options: [
|
|
1809
|
+
{ flag: "--id <number>", description: "File ID", required: true, type: "number" },
|
|
1810
|
+
{ flag: "--confirm", description: "Confirm deletion" }
|
|
1811
|
+
],
|
|
1812
|
+
examples: ["forloop file delete --id 1 --confirm"],
|
|
1813
|
+
async run(_config, flags) {
|
|
1814
|
+
if (!flags.confirm) {
|
|
1815
|
+
console.error("Error: --confirm is required.");
|
|
1816
|
+
process.exit(1);
|
|
1817
|
+
}
|
|
1818
|
+
const resolved = await getClient6();
|
|
1819
|
+
if (!resolved) return;
|
|
1820
|
+
const { client } = resolved;
|
|
1821
|
+
await client.deleteFile(flags.id);
|
|
1822
|
+
console.log(`File #${flags.id} deleted.`);
|
|
1823
|
+
}
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
function createFileDownloadCommand() {
|
|
1827
|
+
return defineCommand({
|
|
1828
|
+
name: "file download",
|
|
1829
|
+
description: "Get a download URL for a file",
|
|
1830
|
+
usage: "forloop file download --id <number>",
|
|
1831
|
+
options: [
|
|
1832
|
+
{ flag: "--id <number>", description: "File ID", required: true, type: "number" }
|
|
1833
|
+
],
|
|
1834
|
+
examples: ["forloop file download --id 1"],
|
|
1835
|
+
async run(_config, flags) {
|
|
1836
|
+
const resolved = await getClient6();
|
|
1837
|
+
if (!resolved) return;
|
|
1838
|
+
const { client } = resolved;
|
|
1839
|
+
await client.logFileRead(flags.id);
|
|
1840
|
+
const download = await client.getFileDownloadUrl(flags.id);
|
|
1841
|
+
console.log(`Download URL: ${download.url}`);
|
|
1842
|
+
}
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
// src/commands/folder.ts
|
|
1847
|
+
function createFolderCommands() {
|
|
1848
|
+
return {
|
|
1849
|
+
"folder create": createFolderCreateCommand()
|
|
1850
|
+
};
|
|
1851
|
+
}
|
|
1852
|
+
function createFolderCreateCommand() {
|
|
1853
|
+
return defineCommand({
|
|
1854
|
+
name: "folder create",
|
|
1855
|
+
description: "Create a document folder for storing files",
|
|
1856
|
+
usage: "forloop folder create --title <text> --sprint <id> [--description <text>] [--permissions <type>]",
|
|
1857
|
+
options: [
|
|
1858
|
+
{ flag: "--title <text>", description: "Folder name", required: true },
|
|
1859
|
+
{ flag: "--sprint <id>", description: "Target sprint ID", required: true, type: "number" },
|
|
1860
|
+
{ flag: "--description <text>", description: "Folder description" },
|
|
1861
|
+
{ flag: "--permissions <type>", description: "public, team, private (default: team)" }
|
|
1862
|
+
],
|
|
1863
|
+
examples: ['forloop folder create --title "Project Docs" --sprint 1 --permissions team'],
|
|
1864
|
+
async run(_config, flags) {
|
|
1865
|
+
const config = getConfig();
|
|
1866
|
+
const token = await getToken();
|
|
1867
|
+
if (!token) {
|
|
1868
|
+
console.error("Error: No API token configured.");
|
|
1869
|
+
process.exit(1);
|
|
1870
|
+
}
|
|
1871
|
+
const client = new ForLoopAPIClient({ token, baseUrl: config.apiUrl });
|
|
1872
|
+
const story = await client.createStory({
|
|
1873
|
+
title: flags.title,
|
|
1874
|
+
description: flags.description,
|
|
1875
|
+
sprintId: flags.sprint,
|
|
1876
|
+
type: "doc_folder",
|
|
1877
|
+
status: "todo",
|
|
1878
|
+
metadata: JSON.stringify({ permissions: flags.permissions || "team" })
|
|
1879
|
+
});
|
|
1880
|
+
console.log(`Document folder created: #${story.id} - ${story.title}`);
|
|
1881
|
+
}
|
|
1882
|
+
});
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
// src/commands/sync.ts
|
|
1886
|
+
import * as fs5 from "fs";
|
|
1887
|
+
import * as path5 from "path";
|
|
1888
|
+
function createSyncCommands() {
|
|
1889
|
+
return {
|
|
1890
|
+
"sync aivy-folder": createSyncAivyFolderCommand(),
|
|
1891
|
+
"sync aivy-doc-get": createAivyDocGetCommand(),
|
|
1892
|
+
"sync s3-to-local": createS3ToLocalCommand(),
|
|
1893
|
+
"sync local-to-s3": createLocalToS3Command()
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
async function getClient7() {
|
|
1897
|
+
const config = getConfig();
|
|
1898
|
+
const token = await getToken();
|
|
1899
|
+
if (!token) {
|
|
1900
|
+
console.error("Error: No API token configured.");
|
|
1901
|
+
process.exit(1);
|
|
1902
|
+
}
|
|
1903
|
+
return { client: new ForLoopAPIClient({ token, baseUrl: config.apiUrl }) };
|
|
1904
|
+
}
|
|
1905
|
+
function createSyncAivyFolderCommand() {
|
|
1906
|
+
return defineCommand({
|
|
1907
|
+
name: "sync aivy-folder",
|
|
1908
|
+
description: "Ensure a doc_folder exists for file sync in the working sprint",
|
|
1909
|
+
usage: "forloop sync aivy-folder [--sprint <id>] [--title <text>]",
|
|
1910
|
+
options: [
|
|
1911
|
+
{ flag: "--sprint <id>", description: "Target sprint ID", type: "number" },
|
|
1912
|
+
{ flag: "--title <text>", description: 'Doc folder title (default: "forloop Aivy doc")' }
|
|
1913
|
+
],
|
|
1914
|
+
examples: ["forloop sync aivy-folder --sprint 1"],
|
|
1915
|
+
async run(_config, flags) {
|
|
1916
|
+
const resolved = await getClient7();
|
|
1917
|
+
if (!resolved) return;
|
|
1918
|
+
const { client } = resolved;
|
|
1919
|
+
const resolution = await resolveSprintId(flags.sprint);
|
|
1920
|
+
if (!resolution.sprintId) {
|
|
1921
|
+
console.error("Error: No sprint ID resolved.");
|
|
1922
|
+
process.exit(1);
|
|
1923
|
+
}
|
|
1924
|
+
const title = flags.title || "forloop Aivy doc";
|
|
1925
|
+
const sprint = await client.getSprint(resolution.sprintId, { includeStories: true, includeFiles: false });
|
|
1926
|
+
const stories = Array.isArray(sprint?.stories) ? sprint.stories : [];
|
|
1927
|
+
const existing = stories.find((s) => s.type === "doc_folder" && s.title?.toLowerCase() === title.toLowerCase());
|
|
1928
|
+
if (existing?.id) {
|
|
1929
|
+
console.log(`Doc folder already exists: #${existing.id} "${existing.title}"`);
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
const created = await client.createStory({
|
|
1933
|
+
sprintId: resolution.sprintId,
|
|
1934
|
+
type: "doc_folder",
|
|
1935
|
+
title,
|
|
1936
|
+
status: "todo"
|
|
1937
|
+
});
|
|
1938
|
+
console.log(`Doc folder created: #${created.id} "${created.title}"`);
|
|
1939
|
+
}
|
|
1940
|
+
});
|
|
1941
|
+
}
|
|
1942
|
+
function createAivyDocGetCommand() {
|
|
1943
|
+
return defineCommand({
|
|
1944
|
+
name: "sync aivy-doc-get",
|
|
1945
|
+
description: "Get the doc_folder story ID for linking files",
|
|
1946
|
+
usage: "forloop sync aivy-doc-get [--sprint <id>] [--title <text>]",
|
|
1947
|
+
options: [
|
|
1948
|
+
{ flag: "--sprint <id>", description: "Target sprint ID", type: "number" },
|
|
1949
|
+
{ flag: "--title <text>", description: 'Doc folder title (default: "forloop Aivy doc")' }
|
|
1950
|
+
],
|
|
1951
|
+
examples: ["forloop sync aivy-doc-get --sprint 1"],
|
|
1952
|
+
async run(_config, flags) {
|
|
1953
|
+
const resolved = await getClient7();
|
|
1954
|
+
if (!resolved) return;
|
|
1955
|
+
const { client } = resolved;
|
|
1956
|
+
const resolution = await resolveSprintId(flags.sprint);
|
|
1957
|
+
if (!resolution.sprintId) {
|
|
1958
|
+
console.error("Error: No sprint ID resolved.");
|
|
1959
|
+
process.exit(1);
|
|
1960
|
+
}
|
|
1961
|
+
const title = flags.title || "forloop Aivy doc";
|
|
1962
|
+
const sprint = await client.getSprint(resolution.sprintId, { includeStories: true, includeFiles: false });
|
|
1963
|
+
const stories = Array.isArray(sprint?.stories) ? sprint.stories : [];
|
|
1964
|
+
const existing = stories.find((s) => s.type === "doc_folder" && s.title?.toLowerCase() === title.toLowerCase());
|
|
1965
|
+
if (existing?.id) {
|
|
1966
|
+
console.log(`Doc folder: #${existing.id} "${existing.title}" (Sprint #${resolution.sprintId})`);
|
|
1967
|
+
} else {
|
|
1968
|
+
console.log(`Doc folder not found. Run: forloop sync aivy-folder --sprint ${resolution.sprintId}`);
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
});
|
|
1972
|
+
}
|
|
1973
|
+
function createS3ToLocalCommand() {
|
|
1974
|
+
return defineCommand({
|
|
1975
|
+
name: "sync s3-to-local",
|
|
1976
|
+
description: "Download sprint files from S3 to local ~/.forloop/ directory",
|
|
1977
|
+
usage: "forloop sync s3-to-local [--sprint <id>] [--no-knowledge] [--no-plans] [--no-tasks] [--overwrite]",
|
|
1978
|
+
options: [
|
|
1979
|
+
{ flag: "--sprint <id>", description: "Target sprint ID", type: "number" },
|
|
1980
|
+
{ flag: "--no-knowledge", description: "Skip knowledge files" },
|
|
1981
|
+
{ flag: "--no-plans", description: "Skip plan files" },
|
|
1982
|
+
{ flag: "--no-tasks", description: "Skip task files" },
|
|
1983
|
+
{ flag: "--overwrite", description: "Overwrite existing local files" }
|
|
1984
|
+
],
|
|
1985
|
+
examples: ["forloop sync s3-to-local --sprint 1"],
|
|
1986
|
+
async run(_config, flags) {
|
|
1987
|
+
const resolved = await getClient7();
|
|
1988
|
+
if (!resolved) return;
|
|
1989
|
+
const { client } = resolved;
|
|
1990
|
+
const resolution = await resolveSprintId(flags.sprint);
|
|
1991
|
+
if (!resolution.sprintId) {
|
|
1992
|
+
console.error("Error: No sprint ID resolved.");
|
|
1993
|
+
process.exit(1);
|
|
1994
|
+
}
|
|
1995
|
+
const files = await client.getSprintFiles(resolution.sprintId);
|
|
1996
|
+
const localRoot = getForloopRoot(resolution.sprintId);
|
|
1997
|
+
fs5.mkdirSync(localRoot, { recursive: true });
|
|
1998
|
+
let downloaded = 0;
|
|
1999
|
+
let skipped = 0;
|
|
2000
|
+
for (const file of files) {
|
|
2001
|
+
const name = (file.originalName || file.filename || "").toLowerCase();
|
|
2002
|
+
const skip = flags.noKnowledge && name.startsWith("knowledge-") || flags.noPlans && name.startsWith("plan-") || flags.noTasks && name.startsWith("task-");
|
|
2003
|
+
if (skip) {
|
|
2004
|
+
skipped++;
|
|
2005
|
+
continue;
|
|
2006
|
+
}
|
|
2007
|
+
const open = await client.getFileOpenUrl(file.id);
|
|
2008
|
+
const res = await fetch(open.url);
|
|
2009
|
+
if (res.ok) {
|
|
2010
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
2011
|
+
const kind = name.startsWith("knowledge-") ? "knowledge" : name.startsWith("plan-") ? "plan" : name.startsWith("task-") ? "task" : "other";
|
|
2012
|
+
const dir = path5.join(localRoot, kind);
|
|
2013
|
+
fs5.mkdirSync(dir, { recursive: true });
|
|
2014
|
+
fs5.writeFileSync(path5.join(dir, name), buf);
|
|
2015
|
+
downloaded++;
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
console.log(`Sync complete: ${downloaded} downloaded, ${skipped} skipped.`);
|
|
2019
|
+
}
|
|
2020
|
+
});
|
|
2021
|
+
}
|
|
2022
|
+
function createLocalToS3Command() {
|
|
2023
|
+
return defineCommand({
|
|
2024
|
+
name: "sync local-to-s3",
|
|
2025
|
+
description: "Upload a local file to sprint S3",
|
|
2026
|
+
usage: "forloop sync local-to-s3 --path <path> [--sprint <id>] [--folder <text>] [--delete]",
|
|
2027
|
+
options: [
|
|
2028
|
+
{ flag: "--path <path>", description: "Local file path", required: true },
|
|
2029
|
+
{ flag: "--sprint <id>", description: "Target sprint ID", type: "number" },
|
|
2030
|
+
{ flag: "--folder <text>", description: "Remote folder (e.g., project/plans)" },
|
|
2031
|
+
{ flag: "--delete", description: "Delete remote file instead of uploading" }
|
|
2032
|
+
],
|
|
2033
|
+
examples: ["forloop sync local-to-s3 --path ~/.forloop/sprint-1/plan/plan.md --sprint 1"],
|
|
2034
|
+
async run(_config, flags) {
|
|
2035
|
+
const resolved = await getClient7();
|
|
2036
|
+
if (!resolved) return;
|
|
2037
|
+
const { client } = resolved;
|
|
2038
|
+
const resolution = await resolveSprintId(flags.sprint);
|
|
2039
|
+
if (!resolution.sprintId) {
|
|
2040
|
+
console.error("Error: No sprint ID resolved.");
|
|
2041
|
+
process.exit(1);
|
|
2042
|
+
}
|
|
2043
|
+
const filePath = flags.path;
|
|
2044
|
+
if (!fs5.existsSync(filePath)) {
|
|
2045
|
+
console.error(`Error: File not found: ${filePath}`);
|
|
2046
|
+
process.exit(1);
|
|
2047
|
+
}
|
|
2048
|
+
const fileBuf = fs5.readFileSync(filePath);
|
|
2049
|
+
const fileName = path5.basename(filePath);
|
|
2050
|
+
const contentType = "text/markdown";
|
|
2051
|
+
const folder = flags.folder || "project";
|
|
2052
|
+
const presign = await client.createPresignedUpload({
|
|
2053
|
+
sprintId: resolution.sprintId,
|
|
2054
|
+
contentType,
|
|
2055
|
+
originalName: fileName,
|
|
2056
|
+
folder
|
|
2057
|
+
});
|
|
2058
|
+
await fetch(presign.uploadUrl, {
|
|
2059
|
+
method: "PUT",
|
|
2060
|
+
headers: { "Content-Type": contentType },
|
|
2061
|
+
body: fileBuf
|
|
2062
|
+
});
|
|
2063
|
+
await client.completeUpload({
|
|
2064
|
+
sprintId: resolution.sprintId,
|
|
2065
|
+
fileName: presign.fileName,
|
|
2066
|
+
originalName: fileName,
|
|
2067
|
+
fileType: contentType,
|
|
2068
|
+
size: fileBuf.length,
|
|
2069
|
+
folder
|
|
2070
|
+
});
|
|
2071
|
+
console.log(`Synced: ${fileName} -> Sprint #${resolution.sprintId}`);
|
|
2072
|
+
}
|
|
2073
|
+
});
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
// src/registry.ts
|
|
2077
|
+
var CommandRegistry = class {
|
|
2078
|
+
root = { children: /* @__PURE__ */ new Map() };
|
|
2079
|
+
constructor(commands) {
|
|
2080
|
+
for (const [path6, cmd] of Object.entries(commands)) {
|
|
2081
|
+
this.register(path6, cmd);
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
register(path6, command) {
|
|
2085
|
+
const parts = path6.split(" ");
|
|
2086
|
+
let node = this.root;
|
|
2087
|
+
for (const part of parts) {
|
|
2088
|
+
if (!node.children.has(part)) {
|
|
2089
|
+
node.children.set(part, { children: /* @__PURE__ */ new Map() });
|
|
2090
|
+
}
|
|
2091
|
+
node = node.children.get(part);
|
|
2092
|
+
}
|
|
2093
|
+
node.command = command;
|
|
2094
|
+
}
|
|
2095
|
+
getAllCommands() {
|
|
2096
|
+
const commands = [];
|
|
2097
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2098
|
+
const traverse = (node) => {
|
|
2099
|
+
if (node.command && !seen.has(node.command)) {
|
|
2100
|
+
seen.add(node.command);
|
|
2101
|
+
commands.push(node.command);
|
|
2102
|
+
}
|
|
2103
|
+
for (const child of node.children.values()) {
|
|
2104
|
+
traverse(child);
|
|
2105
|
+
}
|
|
2106
|
+
};
|
|
2107
|
+
traverse(this.root);
|
|
2108
|
+
return commands;
|
|
2109
|
+
}
|
|
2110
|
+
resolve(commandPath) {
|
|
2111
|
+
let node = this.root;
|
|
2112
|
+
const matched = [];
|
|
2113
|
+
for (const part of commandPath) {
|
|
2114
|
+
const child = node.children.get(part);
|
|
2115
|
+
if (!child) break;
|
|
2116
|
+
node = child;
|
|
2117
|
+
matched.push(part);
|
|
2118
|
+
}
|
|
2119
|
+
if (node.command) {
|
|
2120
|
+
return { command: node.command, extra: commandPath.slice(matched.length) };
|
|
2121
|
+
}
|
|
2122
|
+
if (matched.length > 0 && node.children.size === 1) {
|
|
2123
|
+
const [, child] = node.children.entries().next().value;
|
|
2124
|
+
if (child.command) {
|
|
2125
|
+
return { command: child.command, extra: commandPath.slice(matched.length) };
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
if (matched.length > 0 && node.children.size > 1) {
|
|
2129
|
+
const children = Array.from(node.children.values());
|
|
2130
|
+
const commands = children.map((c) => c.command);
|
|
2131
|
+
const first = commands[0];
|
|
2132
|
+
if (first && commands.every((c) => c === first)) {
|
|
2133
|
+
return { command: first, extra: commandPath.slice(matched.length) };
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
if (matched.length > 0 && node.children.size > 0) {
|
|
2137
|
+
const subcommands = Array.from(node.children.entries()).map(([name, n]) => {
|
|
2138
|
+
if (n.command) return ` ${matched.join(" ")} ${name} ${n.command.description}`;
|
|
2139
|
+
const subs = Array.from(n.children.keys()).join(", ");
|
|
2140
|
+
return ` ${matched.join(" ")} ${name} [${subs}]`;
|
|
2141
|
+
}).join("\n");
|
|
2142
|
+
throw new CLIError(
|
|
2143
|
+
`Unknown command: forloop ${commandPath.join(" ")}
|
|
2144
|
+
|
|
2145
|
+
Available commands:
|
|
2146
|
+
${subcommands}`,
|
|
2147
|
+
2 /* USAGE */,
|
|
2148
|
+
`forloop ${matched.join(" ")} --help`
|
|
2149
|
+
);
|
|
2150
|
+
}
|
|
2151
|
+
throw new CLIError(
|
|
2152
|
+
`Unknown command: forloop ${commandPath.join(" ")}`,
|
|
2153
|
+
2 /* USAGE */,
|
|
2154
|
+
"forloop --help"
|
|
2155
|
+
);
|
|
2156
|
+
}
|
|
2157
|
+
// Color helpers
|
|
2158
|
+
bold = (s, out) => out.isTTY ? `\x1B[1m${s}\x1B[0m` : s;
|
|
2159
|
+
accent = (s, out) => out.isTTY ? `\x1B[38;2;59;130;246m${s}\x1B[0m` : s;
|
|
2160
|
+
dim = (s, out) => out.isTTY ? `\x1B[2m${s}\x1B[0m` : s;
|
|
2161
|
+
printHelp(commandPath, out = process.stdout) {
|
|
2162
|
+
if (commandPath.length === 0) {
|
|
2163
|
+
this.printRootHelp(out);
|
|
2164
|
+
return;
|
|
2165
|
+
}
|
|
2166
|
+
let node = this.root;
|
|
2167
|
+
for (const part of commandPath) {
|
|
2168
|
+
const child = node.children.get(part);
|
|
2169
|
+
if (!child) {
|
|
2170
|
+
this.printRootHelp(out);
|
|
2171
|
+
return;
|
|
2172
|
+
}
|
|
2173
|
+
node = child;
|
|
2174
|
+
}
|
|
2175
|
+
if (node.command) {
|
|
2176
|
+
this.printCommandHelp(node.command, out);
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
const prefix = commandPath.join(" ");
|
|
2180
|
+
out.write(`
|
|
2181
|
+
${this.bold("Usage:", out)} forloop ${prefix} <command> [flags]
|
|
2182
|
+
|
|
2183
|
+
`);
|
|
2184
|
+
out.write(`${this.bold("Commands:", out)}
|
|
2185
|
+
`);
|
|
2186
|
+
this.printChildren(node, prefix, out);
|
|
2187
|
+
out.write("\n");
|
|
2188
|
+
}
|
|
2189
|
+
printRootHelp(out) {
|
|
2190
|
+
const LOGO = [
|
|
2191
|
+
" \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 ",
|
|
2192
|
+
" \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557",
|
|
2193
|
+
" \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D",
|
|
2194
|
+
" \u2588\u2588\u2554\u2550\u2550\u255D \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2550\u255D ",
|
|
2195
|
+
" \u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2554\u255D \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u255A\u2588\u2588\u2588\u2588\u2554\u255D\u255A\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2551\u2588\u2588\u2551 ",
|
|
2196
|
+
" \u255A\u2550\u255D \u255A\u2550\u2550\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u255D "
|
|
2197
|
+
];
|
|
2198
|
+
const BLUE = [59, 130, 246];
|
|
2199
|
+
out.write("\n");
|
|
2200
|
+
for (const line of LOGO) {
|
|
2201
|
+
if (out.isTTY) {
|
|
2202
|
+
out.write(`\x1B[1;38;2;${BLUE[0]};${BLUE[1]};${BLUE[2]}m${line}\x1B[0m
|
|
2203
|
+
`);
|
|
2204
|
+
} else {
|
|
2205
|
+
out.write(line + "\n");
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
const b = (s) => this.bold(s, out);
|
|
2209
|
+
const a = (s) => this.accent(s, out);
|
|
2210
|
+
const d = (s) => this.dim(s, out);
|
|
2211
|
+
out.write(`
|
|
2212
|
+
${b("Usage:")} forloop <resource> <command> [flags]
|
|
2213
|
+
|
|
2214
|
+
${b("Resources:")}
|
|
2215
|
+
${a("auth")} ${d("Authentication (login, status, logout)")}
|
|
2216
|
+
${a("sprint")} ${d("Sprint operations (list, get, create, update, delete)")}
|
|
2217
|
+
${a("story")} ${d("Story operations (create, get, update, delete)")}
|
|
2218
|
+
${a("template")} ${d("Template operations (list)")}
|
|
2219
|
+
${a("agent")} ${d("Agent operations (history, developer-sprint, developer-status)")}
|
|
2220
|
+
${a("org")} ${d("Organization operations (list, get, create, update)")}
|
|
2221
|
+
${a("user")} ${d("User operations (profile, quotas)")}
|
|
2222
|
+
${a("file")} ${d("File operations (upload, list, delete, download)")}
|
|
2223
|
+
${a("folder")} ${d("Folder operations (create)")}
|
|
2224
|
+
${a("sync")} ${d("Sync operations (aivy-folder, aivy-doc-get, s3-to-local, local-to-s3)")}
|
|
2225
|
+
|
|
2226
|
+
${b("Global Flags:")}
|
|
2227
|
+
${a("--api-key <key>")} ${d("ForLoop API key")}
|
|
2228
|
+
${a("--api-url <url>")} ${d("API base URL (overrides config)")}
|
|
2229
|
+
${a("--output <format>")} ${d("Output format: text, json")}
|
|
2230
|
+
${a("--quiet")} ${d("Suppress non-essential output")}
|
|
2231
|
+
${a("--verbose")} ${d("Print HTTP request/response details")}
|
|
2232
|
+
${a("--timeout <seconds>")} ${d("Request timeout (default: 60)")}
|
|
2233
|
+
${a("--no-color")} ${d("Disable ANSI colors")}
|
|
2234
|
+
${a("--dry-run")} ${d("Show what would happen without executing")}
|
|
2235
|
+
${a("--non-interactive")} ${d("Disable interactive prompts (CI mode)")}
|
|
2236
|
+
${a("--version")} ${d("Print version and exit")}
|
|
2237
|
+
${a("--help")} ${d("Show help")}
|
|
2238
|
+
|
|
2239
|
+
${b("Getting Help:")}
|
|
2240
|
+
${d("Add --help after any command to see options and examples.")}
|
|
2241
|
+
${d("For example:")} forloop story get --help
|
|
2242
|
+
`);
|
|
2243
|
+
}
|
|
2244
|
+
printCommandHelp(cmd, out) {
|
|
2245
|
+
const b = (s) => this.bold(s, out);
|
|
2246
|
+
const a = (s) => this.accent(s, out);
|
|
2247
|
+
const d = (s) => this.dim(s, out);
|
|
2248
|
+
out.write(`
|
|
2249
|
+
${cmd.description}
|
|
2250
|
+
`);
|
|
2251
|
+
if (cmd.usage) out.write(`${b("Usage:")} ${cmd.usage}
|
|
2252
|
+
`);
|
|
2253
|
+
if (cmd.options && cmd.options.length > 0) {
|
|
2254
|
+
const maxLen = Math.max(...cmd.options.map((o) => o.flag.length));
|
|
2255
|
+
out.write(`
|
|
2256
|
+
${b("Options:")}
|
|
2257
|
+
`);
|
|
2258
|
+
for (const opt of cmd.options) {
|
|
2259
|
+
out.write(` ${a(opt.flag.padEnd(maxLen + 2))} ${d(opt.description)}
|
|
2260
|
+
`);
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
if (cmd.examples && cmd.examples.length > 0) {
|
|
2264
|
+
out.write(`
|
|
2265
|
+
${b("Examples:")}
|
|
2266
|
+
`);
|
|
2267
|
+
for (const ex of cmd.examples) {
|
|
2268
|
+
out.write(` ${d(ex)}
|
|
2269
|
+
`);
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
out.write(`
|
|
2273
|
+
${d("Global flags (--api-key, --output, --quiet, etc.) are always available.")}
|
|
2274
|
+
`);
|
|
2275
|
+
out.write(`${d("Run")} forloop --help ${d("for the full list.")}
|
|
2276
|
+
`);
|
|
2277
|
+
}
|
|
2278
|
+
printChildren(node, prefix, out) {
|
|
2279
|
+
const entries = [];
|
|
2280
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2281
|
+
const collect = (n, p) => {
|
|
2282
|
+
for (const [name, child] of n.children) {
|
|
2283
|
+
if (child.command) {
|
|
2284
|
+
const existing = seen.get(child.command);
|
|
2285
|
+
if (existing) continue;
|
|
2286
|
+
seen.set(child.command, name);
|
|
2287
|
+
entries.push({ fullName: `${p} ${name}`, description: child.command.description });
|
|
2288
|
+
}
|
|
2289
|
+
if (child.children.size > 0) collect(child, `${p} ${name}`);
|
|
2290
|
+
}
|
|
2291
|
+
};
|
|
2292
|
+
collect(node, prefix);
|
|
2293
|
+
if (entries.length === 0) return;
|
|
2294
|
+
const maxLen = Math.max(...entries.map((e) => e.fullName.length));
|
|
2295
|
+
for (const { fullName, description } of entries) {
|
|
2296
|
+
out.write(` ${this.accent(fullName.padEnd(maxLen), out)} ${this.dim(description, out)}
|
|
2297
|
+
`);
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
};
|
|
2301
|
+
var sprintGetCmd = createSprintCommands()["sprint get"];
|
|
2302
|
+
var sprintListCmd = createSprintCommands()["sprint list"];
|
|
2303
|
+
var sprintCreateCmd = createSprintCommands()["sprint create"];
|
|
2304
|
+
var sprintUpdateCmd = createSprintCommands()["sprint update"];
|
|
2305
|
+
var sprintDeleteCmd = createSprintCommands()["sprint delete"];
|
|
2306
|
+
var storyCreateCmd = createStoryCommands()["story create"];
|
|
2307
|
+
var storyGetCmd = createStoryCommands()["story get"];
|
|
2308
|
+
var storyUpdateCmd = createStoryCommands()["story update"];
|
|
2309
|
+
var storyDeleteCmd = createStoryCommands()["story delete"];
|
|
2310
|
+
var agentHistoryCmd = createAgentCommands()["agent history"];
|
|
2311
|
+
var agentDeveloperSprintCmd = createAgentCommands()["agent developer-sprint"];
|
|
2312
|
+
var agentDeveloperStatusCmd = createAgentCommands()["agent developer-status"];
|
|
2313
|
+
var fileUploadCmd = createFileCommands()["file upload"];
|
|
2314
|
+
var fileListCmd = createFileCommands()["file list"];
|
|
2315
|
+
var fileDeleteCmd = createFileCommands()["file delete"];
|
|
2316
|
+
var fileDownloadCmd = createFileCommands()["file download"];
|
|
2317
|
+
var registry = new CommandRegistry({
|
|
2318
|
+
...createAuthCommands(),
|
|
2319
|
+
...createStoryCommands(),
|
|
2320
|
+
...createSprintCommands(),
|
|
2321
|
+
...createOrgCommands(),
|
|
2322
|
+
...createTemplateCommands(),
|
|
2323
|
+
...createAgentCommands(),
|
|
2324
|
+
...createUserCommands(),
|
|
2325
|
+
...createFileCommands(),
|
|
2326
|
+
...createFolderCommands(),
|
|
2327
|
+
...createSyncCommands(),
|
|
2328
|
+
// Aliases
|
|
2329
|
+
"sprint show": sprintGetCmd,
|
|
2330
|
+
"sprint info": sprintGetCmd,
|
|
2331
|
+
"agent status": agentDeveloperStatusCmd,
|
|
2332
|
+
"agent sprint": agentDeveloperSprintCmd
|
|
2333
|
+
});
|
|
2334
|
+
|
|
2335
|
+
// src/output/formatter.ts
|
|
2336
|
+
function formatError(message, format) {
|
|
2337
|
+
if (format === "json") {
|
|
2338
|
+
return JSON.stringify({ error: { message } }, null, 2);
|
|
2339
|
+
}
|
|
2340
|
+
return `\u274C ${message}`;
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
// src/errors/handler.ts
|
|
2344
|
+
function handleError(error) {
|
|
2345
|
+
const isJson = process.argv.includes("--output=json");
|
|
2346
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2347
|
+
const exitCode = error instanceof CLIError ? error.exitCode : 1 /* GENERAL */;
|
|
2348
|
+
if (isJson) {
|
|
2349
|
+
console.error(formatError(message, "json"));
|
|
2350
|
+
} else {
|
|
2351
|
+
process.stderr.write(`
|
|
2352
|
+
Error: ${message}
|
|
2353
|
+
`);
|
|
2354
|
+
}
|
|
2355
|
+
const suggestion = error instanceof CLIError ? error.suggestion : void 0;
|
|
2356
|
+
if (suggestion && !isJson) {
|
|
2357
|
+
process.stderr.write(`
|
|
2358
|
+
Tip: ${suggestion}
|
|
2359
|
+
`);
|
|
2360
|
+
}
|
|
2361
|
+
process.exit(exitCode);
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
// src/main.ts
|
|
2365
|
+
var CLI_VERSION = "0.2.0";
|
|
2366
|
+
process.on("SIGINT", () => {
|
|
2367
|
+
process.stderr.write("\nInterrupted. Exiting.\n");
|
|
2368
|
+
process.exit(130);
|
|
2369
|
+
});
|
|
2370
|
+
process.stdout.on("error", (e) => {
|
|
2371
|
+
if (e.code === "EPIPE") process.exit(0);
|
|
2372
|
+
});
|
|
2373
|
+
var NO_AUTH_COMMANDS = [
|
|
2374
|
+
["auth", "login"],
|
|
2375
|
+
["auth", "logout"],
|
|
2376
|
+
["auth", "status"]
|
|
2377
|
+
];
|
|
2378
|
+
async function main() {
|
|
2379
|
+
const argv = process.argv.slice(2);
|
|
2380
|
+
if (argv.includes("--version") || argv.includes("-v")) {
|
|
2381
|
+
console.log(`forloop ${CLI_VERSION}`);
|
|
2382
|
+
process.exit(0);
|
|
2383
|
+
}
|
|
2384
|
+
const commandPath = scanCommandPath(argv, GLOBAL_OPTIONS);
|
|
2385
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
2386
|
+
registry.printHelp(commandPath, process.stderr);
|
|
2387
|
+
process.exit(0);
|
|
2388
|
+
}
|
|
2389
|
+
if (commandPath.length === 0) {
|
|
2390
|
+
registry.printHelp([], process.stderr);
|
|
2391
|
+
try {
|
|
2392
|
+
const token2 = await getToken();
|
|
2393
|
+
if (token2) {
|
|
2394
|
+
const tokenType = detectTokenType(token2);
|
|
2395
|
+
const masked = token2.slice(0, 8) + "..." + token2.slice(-4);
|
|
2396
|
+
process.stderr.write(`
|
|
2397
|
+
Token: ${masked} Type: ${tokenType || "unknown"}
|
|
2398
|
+
`);
|
|
2399
|
+
process.stderr.write(" Use forloop --help for all available commands.\n\n");
|
|
2400
|
+
} else {
|
|
2401
|
+
process.stderr.write("\n Not authenticated.\n");
|
|
2402
|
+
process.stderr.write(" forloop auth login --api-key floop_xxxxx\n\n");
|
|
2403
|
+
}
|
|
2404
|
+
} catch {
|
|
2405
|
+
process.stderr.write("\n Not authenticated.\n");
|
|
2406
|
+
process.stderr.write(" forloop auth login --api-key floop_xxxxx\n\n");
|
|
2407
|
+
}
|
|
2408
|
+
process.exit(0);
|
|
2409
|
+
}
|
|
2410
|
+
const { command, extra } = registry.resolve(commandPath);
|
|
2411
|
+
const flags = parseFlags(argv, [...GLOBAL_OPTIONS, ...command.options ?? []]);
|
|
2412
|
+
if (extra.length > 0) flags._positional = extra;
|
|
2413
|
+
const pluginConfig = getConfig();
|
|
2414
|
+
const token = await getToken();
|
|
2415
|
+
const isNoAuth = NO_AUTH_COMMANDS.some(
|
|
2416
|
+
(cmd) => cmd.every((c, i) => commandPath[i] === c)
|
|
2417
|
+
);
|
|
2418
|
+
if (!isNoAuth && !token) {
|
|
2419
|
+
process.stderr.write("\nError: No ForLoop API token configured.\n");
|
|
2420
|
+
process.stderr.write(" Run: forloop auth login --api-key floop_xxxxx\n");
|
|
2421
|
+
process.stderr.write(" Or set: FORLOOP_API_KEY environment variable\n\n");
|
|
2422
|
+
process.exit(3);
|
|
2423
|
+
}
|
|
2424
|
+
const cliConfig = {
|
|
2425
|
+
region: pluginConfig.apiUrl.includes("dev.forloop.cc") ? "dev" : "global",
|
|
2426
|
+
apiUrl: pluginConfig.apiUrl,
|
|
2427
|
+
serverLambdaUrl: "",
|
|
2428
|
+
environment: pluginConfig.environment,
|
|
2429
|
+
output: flags.output === "json" ? "json" : "text",
|
|
2430
|
+
timeout: flags.timeout ?? 60,
|
|
2431
|
+
verbose: flags.verbose === true,
|
|
2432
|
+
quiet: flags.quiet === true,
|
|
2433
|
+
noColor: flags.noColor === true || !process.stdout.isTTY,
|
|
2434
|
+
dryRun: flags.dryRun === true,
|
|
2435
|
+
nonInteractive: flags.nonInteractive === true,
|
|
2436
|
+
apiKey: flags.apiKey || token
|
|
2437
|
+
};
|
|
2438
|
+
await command.execute(cliConfig, flags);
|
|
2439
|
+
}
|
|
2440
|
+
main().catch(handleError);
|