@dosu/cli 0.16.0 → 0.16.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.
Files changed (2) hide show
  1. package/bin/dosu.js +255 -235
  2. package/package.json +1 -1
package/bin/dosu.js CHANGED
@@ -2082,6 +2082,232 @@ var init_esm = __esm(() => {
2082
2082
  } = import__.default);
2083
2083
  });
2084
2084
 
2085
+ // src/config/config.ts
2086
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2087
+ import { join } from "node:path";
2088
+ function getConfigDir() {
2089
+ const dirName = process.env.DOSU_DEV === "true" ? "dosu-cli-dev" : "dosu-cli";
2090
+ const xdgConfig = process.env.XDG_CONFIG_HOME;
2091
+ if (xdgConfig)
2092
+ return join(xdgConfig, dirName);
2093
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
2094
+ return join(home, ".config", dirName);
2095
+ }
2096
+ function getConfigPath() {
2097
+ const dir = getConfigDir();
2098
+ if (!existsSync(dir)) {
2099
+ mkdirSync(dir, { recursive: true, mode: 448 });
2100
+ }
2101
+ return join(dir, "config.json");
2102
+ }
2103
+ function loadConfig() {
2104
+ const path = getConfigPath();
2105
+ if (!existsSync(path)) {
2106
+ return emptyConfig();
2107
+ }
2108
+ try {
2109
+ const data = readFileSync(path, "utf-8");
2110
+ return JSON.parse(data);
2111
+ } catch {
2112
+ return emptyConfig();
2113
+ }
2114
+ }
2115
+ function saveConfig(cfg) {
2116
+ const path = getConfigPath();
2117
+ const dir = getConfigDir();
2118
+ if (!existsSync(dir)) {
2119
+ mkdirSync(dir, { recursive: true, mode: 448 });
2120
+ }
2121
+ writeFileSync(path, JSON.stringify(cfg, null, 2), { mode: 384 });
2122
+ }
2123
+ function isAuthenticated(cfg) {
2124
+ return cfg.access_token !== "";
2125
+ }
2126
+ function isTokenExpired(cfg) {
2127
+ if (cfg.expires_at === 0)
2128
+ return false;
2129
+ return Math.floor(Date.now() / 1000) > cfg.expires_at - 300;
2130
+ }
2131
+ function emptyConfig() {
2132
+ return {
2133
+ access_token: "",
2134
+ refresh_token: "",
2135
+ expires_at: 0
2136
+ };
2137
+ }
2138
+ var MODE_OSS = "oss";
2139
+ var init_config = () => {};
2140
+
2141
+ // src/config/constants.ts
2142
+ function getWebAppURL() {
2143
+ return process.env.DOSU_WEB_APP_URL_OVERRIDE ?? "https://app.dosu.dev" ?? "";
2144
+ }
2145
+ function getBackendURL() {
2146
+ return process.env.DOSU_BACKEND_URL_OVERRIDE ?? "https://api.dosu.dev" ?? "";
2147
+ }
2148
+ function getSupabaseURL() {
2149
+ return process.env.SUPABASE_URL_OVERRIDE ?? "https://wldmetsoicvieidlsqrb.supabase.co" ?? "";
2150
+ }
2151
+ function getSupabaseAnonKey() {
2152
+ return process.env.SUPABASE_ANON_KEY_OVERRIDE ?? "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6IndsbG1ldHNvaWN2aWVpZGxzcXJiIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzQ0NjA1NjUsImV4cCI6MjA1MDAzNjU2NX0.15LIxqSMVRnxsvLrk0GPjL1aSYPfOeaeFMdJXqgc5Xs" ?? "";
2153
+ }
2154
+
2155
+ // src/client/client.ts
2156
+ var exports_client = {};
2157
+ __export(exports_client, {
2158
+ SessionExpiredError: () => SessionExpiredError,
2159
+ Client: () => Client
2160
+ });
2161
+
2162
+ class Client {
2163
+ baseURL;
2164
+ config;
2165
+ constructor(cfg) {
2166
+ this.baseURL = getBackendURL();
2167
+ this.config = cfg;
2168
+ }
2169
+ async doRequest(method, path, body) {
2170
+ if (!isAuthenticated(this.config)) {
2171
+ throw new Error("not authenticated - please run setup first");
2172
+ }
2173
+ if (isTokenExpired(this.config)) {
2174
+ await this.refreshToken();
2175
+ }
2176
+ let resp = await this.doRequestOnce(method, path, body);
2177
+ if (resp.status === 401 || resp.status === 403) {
2178
+ try {
2179
+ await this.refreshToken();
2180
+ } catch {
2181
+ throw new SessionExpiredError;
2182
+ }
2183
+ resp = await this.doRequestOnce(method, path, body);
2184
+ }
2185
+ return resp;
2186
+ }
2187
+ async doRequestRaw(method, path) {
2188
+ return this.doRequestOnce(method, path);
2189
+ }
2190
+ async doRequestOnce(method, path, body) {
2191
+ const url = this.baseURL + path;
2192
+ const headers = {
2193
+ "Content-Type": "application/json",
2194
+ "Supabase-Access-Token": this.config.access_token
2195
+ };
2196
+ const options = { method, headers };
2197
+ if (body !== undefined) {
2198
+ options.body = JSON.stringify(body);
2199
+ }
2200
+ const controller = new AbortController;
2201
+ const timeout = setTimeout(() => controller.abort(), 1e4);
2202
+ options.signal = controller.signal;
2203
+ try {
2204
+ return await fetch(url, options);
2205
+ } finally {
2206
+ clearTimeout(timeout);
2207
+ }
2208
+ }
2209
+ async get(path) {
2210
+ return this.doRequest("GET", path);
2211
+ }
2212
+ async post(path, body) {
2213
+ return this.doRequest("POST", path, body);
2214
+ }
2215
+ async put(path, body) {
2216
+ return this.doRequest("PUT", path, body);
2217
+ }
2218
+ async delete(path) {
2219
+ return this.doRequest("DELETE", path);
2220
+ }
2221
+ async refreshToken() {
2222
+ if (!this.config.refresh_token) {
2223
+ throw new Error("no refresh token available");
2224
+ }
2225
+ const supabaseURL = getSupabaseURL();
2226
+ const endpoint = `${supabaseURL}/auth/v1/token?grant_type=refresh_token`;
2227
+ const resp = await fetch(endpoint, {
2228
+ method: "POST",
2229
+ headers: {
2230
+ "Content-Type": "application/json",
2231
+ apikey: getSupabaseAnonKey()
2232
+ },
2233
+ body: JSON.stringify({ refresh_token: this.config.refresh_token })
2234
+ });
2235
+ if (resp.status !== 200) {
2236
+ throw new Error(`refresh failed with status ${resp.status}`);
2237
+ }
2238
+ const data = await resp.json();
2239
+ this.config.access_token = data.access_token;
2240
+ this.config.refresh_token = data.refresh_token;
2241
+ this.config.expires_at = Math.floor(Date.now() / 1000) + data.expires_in;
2242
+ saveConfig(this.config);
2243
+ }
2244
+ async getDeployments() {
2245
+ const resp = await this.get("/v1/mcp/deployments");
2246
+ if (resp.status !== 200) {
2247
+ let detail = await readErrorBody(resp);
2248
+ if (!detail || detail === "Internal Server Error") {
2249
+ detail = "check backend logs for details";
2250
+ }
2251
+ throw new Error(`failed to fetch deployments (status ${resp.status}): ${detail}`);
2252
+ }
2253
+ return resp.json();
2254
+ }
2255
+ async getOrgs() {
2256
+ const resp = await this.get("/v1/mcp/orgs");
2257
+ if (resp.status !== 200) {
2258
+ const detail = await readErrorBody(resp);
2259
+ throw new Error(`failed to fetch orgs (status ${resp.status}): ${detail}`);
2260
+ }
2261
+ return resp.json();
2262
+ }
2263
+ async validateAPIKey(apiKey, deploymentID) {
2264
+ try {
2265
+ const endpoint = `${this.baseURL}/v1/mcp/deployments/${encodeURIComponent(deploymentID)}`;
2266
+ const controller = new AbortController;
2267
+ const timeout = setTimeout(() => controller.abort(), 5000);
2268
+ try {
2269
+ const resp = await fetch(endpoint, {
2270
+ method: "GET",
2271
+ headers: { "X-Dosu-API-Key": apiKey },
2272
+ signal: controller.signal
2273
+ });
2274
+ return resp.status !== 401 && resp.status !== 403;
2275
+ } finally {
2276
+ clearTimeout(timeout);
2277
+ }
2278
+ } catch {
2279
+ return true;
2280
+ }
2281
+ }
2282
+ async createAPIKey(deploymentID, name) {
2283
+ const path = `/v1/mcp/deployments/${deploymentID}/api-keys`;
2284
+ const resp = await this.post(path, { name });
2285
+ if (resp.status !== 200 && resp.status !== 201) {
2286
+ const detail = await readErrorBody(resp);
2287
+ throw new Error(`failed to create API key (status ${resp.status}): ${detail}`);
2288
+ }
2289
+ return resp.json();
2290
+ }
2291
+ }
2292
+ async function readErrorBody(resp) {
2293
+ try {
2294
+ const text = await resp.text();
2295
+ return text.slice(0, 1024);
2296
+ } catch {
2297
+ return "";
2298
+ }
2299
+ }
2300
+ var SessionExpiredError;
2301
+ var init_client = __esm(() => {
2302
+ init_config();
2303
+ SessionExpiredError = class SessionExpiredError extends Error {
2304
+ constructor() {
2305
+ super("session expired");
2306
+ this.name = "SessionExpiredError";
2307
+ }
2308
+ };
2309
+ });
2310
+
2085
2311
  // node_modules/picocolors/picocolors.js
2086
2312
  var require_picocolors = __commonJS((exports, module) => {
2087
2313
  var p = process || {};
@@ -4267,81 +4493,11 @@ var init_dist4 = __esm(() => {
4267
4493
  allowErrorProps = SuperJSON.allowErrorProps;
4268
4494
  });
4269
4495
 
4270
- // src/config/config.ts
4271
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4272
- import { join } from "node:path";
4273
- function getConfigDir() {
4274
- const dirName = process.env.DOSU_DEV === "true" ? "dosu-cli-dev" : "dosu-cli";
4275
- const xdgConfig = process.env.XDG_CONFIG_HOME;
4276
- if (xdgConfig)
4277
- return join(xdgConfig, dirName);
4278
- const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
4279
- return join(home, ".config", dirName);
4280
- }
4281
- function getConfigPath() {
4282
- const dir = getConfigDir();
4283
- if (!existsSync(dir)) {
4284
- mkdirSync(dir, { recursive: true, mode: 448 });
4285
- }
4286
- return join(dir, "config.json");
4287
- }
4288
- function loadConfig() {
4289
- const path = getConfigPath();
4290
- if (!existsSync(path)) {
4291
- return emptyConfig();
4292
- }
4293
- try {
4294
- const data = readFileSync(path, "utf-8");
4295
- return JSON.parse(data);
4296
- } catch {
4297
- return emptyConfig();
4298
- }
4299
- }
4300
- function saveConfig(cfg) {
4301
- const path = getConfigPath();
4302
- const dir = getConfigDir();
4303
- if (!existsSync(dir)) {
4304
- mkdirSync(dir, { recursive: true, mode: 448 });
4305
- }
4306
- writeFileSync(path, JSON.stringify(cfg, null, 2), { mode: 384 });
4307
- }
4308
- function isAuthenticated(cfg) {
4309
- return cfg.access_token !== "";
4310
- }
4311
- function isTokenExpired(cfg) {
4312
- if (cfg.expires_at === 0)
4313
- return false;
4314
- return Math.floor(Date.now() / 1000) > cfg.expires_at - 300;
4315
- }
4316
- function emptyConfig() {
4317
- return {
4318
- access_token: "",
4319
- refresh_token: "",
4320
- expires_at: 0
4321
- };
4322
- }
4323
- var MODE_OSS = "oss";
4324
- var init_config = () => {};
4325
-
4326
- // src/config/constants.ts
4327
- function getWebAppURL() {
4328
- return process.env.DOSU_WEB_APP_URL_OVERRIDE ?? "https://app.dosu.dev" ?? "";
4329
- }
4330
- function getBackendURL() {
4331
- return process.env.DOSU_BACKEND_URL_OVERRIDE ?? "https://api.dosu.dev" ?? "";
4332
- }
4333
- function getSupabaseURL() {
4334
- return process.env.SUPABASE_URL_OVERRIDE ?? "https://wldmetsoicvieidlsqrb.supabase.co" ?? "";
4335
- }
4336
- function getSupabaseAnonKey() {
4337
- return process.env.SUPABASE_ANON_KEY_OVERRIDE ?? "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6IndsbG1ldHNvaWN2aWVpZGxzcXJiIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzQ0NjA1NjUsImV4cCI6MjA1MDAzNjU2NX0.15LIxqSMVRnxsvLrk0GPjL1aSYPfOeaeFMdJXqgc5Xs" ?? "";
4338
- }
4339
-
4340
4496
  // src/version/version.ts
4341
4497
  function getVersionString() {
4342
4498
  return `v${VERSION}`;
4343
4499
  }
4344
- var VERSION = "0.16.0";
4500
+ var VERSION = "0.16.1";
4345
4501
 
4346
4502
  // src/debug/logger.ts
4347
4503
  import {
@@ -4474,162 +4630,6 @@ var init_logger = __esm(() => {
4474
4630
  };
4475
4631
  });
4476
4632
 
4477
- // src/client/client.ts
4478
- var exports_client = {};
4479
- __export(exports_client, {
4480
- SessionExpiredError: () => SessionExpiredError,
4481
- Client: () => Client
4482
- });
4483
-
4484
- class Client {
4485
- baseURL;
4486
- config;
4487
- constructor(cfg) {
4488
- this.baseURL = getBackendURL();
4489
- this.config = cfg;
4490
- }
4491
- async doRequest(method, path, body) {
4492
- if (!isAuthenticated(this.config)) {
4493
- throw new Error("not authenticated - please run setup first");
4494
- }
4495
- if (isTokenExpired(this.config)) {
4496
- await this.refreshToken();
4497
- }
4498
- let resp = await this.doRequestOnce(method, path, body);
4499
- if (resp.status === 401 || resp.status === 403) {
4500
- try {
4501
- await this.refreshToken();
4502
- } catch {
4503
- throw new SessionExpiredError;
4504
- }
4505
- resp = await this.doRequestOnce(method, path, body);
4506
- }
4507
- return resp;
4508
- }
4509
- async doRequestRaw(method, path) {
4510
- return this.doRequestOnce(method, path);
4511
- }
4512
- async doRequestOnce(method, path, body) {
4513
- const url = this.baseURL + path;
4514
- const headers = {
4515
- "Content-Type": "application/json",
4516
- "Supabase-Access-Token": this.config.access_token
4517
- };
4518
- const options = { method, headers };
4519
- if (body !== undefined) {
4520
- options.body = JSON.stringify(body);
4521
- }
4522
- const controller = new AbortController;
4523
- const timeout = setTimeout(() => controller.abort(), 1e4);
4524
- options.signal = controller.signal;
4525
- try {
4526
- return await fetch(url, options);
4527
- } finally {
4528
- clearTimeout(timeout);
4529
- }
4530
- }
4531
- async get(path) {
4532
- return this.doRequest("GET", path);
4533
- }
4534
- async post(path, body) {
4535
- return this.doRequest("POST", path, body);
4536
- }
4537
- async put(path, body) {
4538
- return this.doRequest("PUT", path, body);
4539
- }
4540
- async delete(path) {
4541
- return this.doRequest("DELETE", path);
4542
- }
4543
- async refreshToken() {
4544
- if (!this.config.refresh_token) {
4545
- throw new Error("no refresh token available");
4546
- }
4547
- const supabaseURL = getSupabaseURL();
4548
- const endpoint = `${supabaseURL}/auth/v1/token?grant_type=refresh_token`;
4549
- const resp = await fetch(endpoint, {
4550
- method: "POST",
4551
- headers: {
4552
- "Content-Type": "application/json",
4553
- apikey: getSupabaseAnonKey()
4554
- },
4555
- body: JSON.stringify({ refresh_token: this.config.refresh_token })
4556
- });
4557
- if (resp.status !== 200) {
4558
- throw new Error(`refresh failed with status ${resp.status}`);
4559
- }
4560
- const data = await resp.json();
4561
- this.config.access_token = data.access_token;
4562
- this.config.refresh_token = data.refresh_token;
4563
- this.config.expires_at = Math.floor(Date.now() / 1000) + data.expires_in;
4564
- saveConfig(this.config);
4565
- }
4566
- async getDeployments() {
4567
- const resp = await this.get("/v1/mcp/deployments");
4568
- if (resp.status !== 200) {
4569
- let detail = await readErrorBody(resp);
4570
- if (!detail || detail === "Internal Server Error") {
4571
- detail = "check backend logs for details";
4572
- }
4573
- throw new Error(`failed to fetch deployments (status ${resp.status}): ${detail}`);
4574
- }
4575
- return resp.json();
4576
- }
4577
- async getOrgs() {
4578
- const resp = await this.get("/v1/mcp/orgs");
4579
- if (resp.status !== 200) {
4580
- const detail = await readErrorBody(resp);
4581
- throw new Error(`failed to fetch orgs (status ${resp.status}): ${detail}`);
4582
- }
4583
- return resp.json();
4584
- }
4585
- async validateAPIKey(apiKey, deploymentID) {
4586
- try {
4587
- const endpoint = `${this.baseURL}/v1/mcp/deployments/${encodeURIComponent(deploymentID)}`;
4588
- const controller = new AbortController;
4589
- const timeout = setTimeout(() => controller.abort(), 5000);
4590
- try {
4591
- const resp = await fetch(endpoint, {
4592
- method: "GET",
4593
- headers: { "X-Dosu-API-Key": apiKey },
4594
- signal: controller.signal
4595
- });
4596
- return resp.status !== 401 && resp.status !== 403;
4597
- } finally {
4598
- clearTimeout(timeout);
4599
- }
4600
- } catch {
4601
- return true;
4602
- }
4603
- }
4604
- async createAPIKey(deploymentID, name) {
4605
- const path = `/v1/mcp/deployments/${deploymentID}/api-keys`;
4606
- const resp = await this.post(path, { name });
4607
- if (resp.status !== 200 && resp.status !== 201) {
4608
- const detail = await readErrorBody(resp);
4609
- throw new Error(`failed to create API key (status ${resp.status}): ${detail}`);
4610
- }
4611
- return resp.json();
4612
- }
4613
- }
4614
- async function readErrorBody(resp) {
4615
- try {
4616
- const text = await resp.text();
4617
- return text.slice(0, 1024);
4618
- } catch {
4619
- return "";
4620
- }
4621
- }
4622
- var SessionExpiredError;
4623
- var init_client = __esm(() => {
4624
- init_config();
4625
- SessionExpiredError = class SessionExpiredError extends Error {
4626
- constructor() {
4627
- super("session expired");
4628
- this.name = "SessionExpiredError";
4629
- }
4630
- };
4631
- });
4632
-
4633
4633
  // src/client/trpc.ts
4634
4634
  var exports_trpc = {};
4635
4635
  __export(exports_trpc, {
@@ -12239,6 +12239,7 @@ var init_flow3 = __esm(() => {
12239
12239
 
12240
12240
  // src/cli/cli.ts
12241
12241
  init_esm();
12242
+ init_client();
12242
12243
  import { readFileSync as readFileSync11, unlinkSync as unlinkSync2 } from "node:fs";
12243
12244
 
12244
12245
  // src/commands/analytics.ts
@@ -14341,10 +14342,17 @@ function createProgram() {
14341
14342
  return;
14342
14343
  }
14343
14344
  const cfg = loadConfig();
14344
- if (isAuthenticated(cfg) && !isTokenExpired(cfg)) {
14345
- console.log("You are already logged in.");
14346
- console.log("Run 'dosu logout' first to re-authenticate.");
14347
- return;
14345
+ if (isAuthenticated(cfg)) {
14346
+ if (!isTokenExpired(cfg)) {
14347
+ console.log("You are already logged in.");
14348
+ console.log("Run 'dosu logout' first to re-authenticate.");
14349
+ return;
14350
+ }
14351
+ if (await ensureFreshSession(cfg)) {
14352
+ console.log("Session refreshed.");
14353
+ console.log(`Credentials saved to ${getConfigPath()}`);
14354
+ return;
14355
+ }
14348
14356
  }
14349
14357
  console.log("Opening browser for authentication...");
14350
14358
  const { startOAuthFlow: startOAuthFlow2 } = await Promise.resolve().then(() => (init_flow(), exports_flow));
@@ -14384,14 +14392,14 @@ function createProgram() {
14384
14392
  saveConfig(cfg);
14385
14393
  console.log("Successfully logged out.");
14386
14394
  });
14387
- program2.command("status").description("Show current authentication and MCP status").action(() => {
14395
+ program2.command("status").description("Show current authentication and MCP status").action(async () => {
14388
14396
  const cfg = loadConfig();
14389
14397
  if (!isAuthenticated(cfg)) {
14390
14398
  console.log("Status: Not logged in");
14391
14399
  console.log("Run 'dosu login' to authenticate.");
14392
14400
  return;
14393
14401
  }
14394
- if (isTokenExpired(cfg)) {
14402
+ if (isTokenExpired(cfg) && !await ensureFreshSession(cfg)) {
14395
14403
  console.log("Status: Token expired");
14396
14404
  console.log("Run 'dosu login' to re-authenticate.");
14397
14405
  } else {
@@ -14409,7 +14417,7 @@ function createProgram() {
14409
14417
  }
14410
14418
  });
14411
14419
  const mcp = program2.command("mcp").description("Manage MCP server integrations");
14412
- mcp.command("add <agent>").description("Add Dosu MCP to an AI tool").option("-g, --global", "Add globally (all projects) instead of project-local", false).option("--show-secret", "Print full manual configuration secrets", false).action((toolId, opts) => {
14420
+ mcp.command("add <agent>").description("Add Dosu MCP to an AI tool").option("-g, --global", "Add globally (all projects) instead of project-local", false).option("--show-secret", "Print full manual configuration secrets", false).action(async (toolId, opts) => {
14413
14421
  let provider;
14414
14422
  try {
14415
14423
  provider = getProvider(toolId.toLowerCase());
@@ -14420,7 +14428,7 @@ function createProgram() {
14420
14428
  if (!isAuthenticated(cfg)) {
14421
14429
  throw new Error("not logged in. Run 'dosu login' first");
14422
14430
  }
14423
- if (isTokenExpired(cfg)) {
14431
+ if (isTokenExpired(cfg) && !await ensureFreshSession(cfg)) {
14424
14432
  throw new Error("session expired. Run 'dosu login' to re-authenticate");
14425
14433
  }
14426
14434
  if (cfg.mode !== MODE_OSS && !cfg.deployment_id) {
@@ -14545,6 +14553,18 @@ Use 'dosu mcp add <agent>' to add Dosu MCP to a tool.`);
14545
14553
  });
14546
14554
  return program2;
14547
14555
  }
14556
+ async function ensureFreshSession(cfg) {
14557
+ if (!isTokenExpired(cfg))
14558
+ return true;
14559
+ try {
14560
+ logger.debug("cli", "token expired, attempting refresh");
14561
+ await new Client(cfg).refreshToken();
14562
+ return true;
14563
+ } catch (err) {
14564
+ logger.debug("cli", `token refresh failed: ${err instanceof Error ? err.message : err}`);
14565
+ return false;
14566
+ }
14567
+ }
14548
14568
  async function execute() {
14549
14569
  const program2 = createProgram();
14550
14570
  await program2.parseAsync(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dosu/cli",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "type": "module",
5
5
  "description": "Dosu CLI - Manage MCP servers for AI tools",
6
6
  "license": "MIT",