@allocator-one/harness-cli 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,7 +56,7 @@ tool — issue_list, issue_show, issue_create, issue_update, issue_claim,
56
56
  issue_close, issue_comment, relation_add/rm, link_list/add/rm,
57
57
  project_list/show/create/update, milestone_list/create,
58
58
  document_list/show/create/update, label_list/create, team_list,
59
- workflow_states, user_list, stats, upload. Read-only tools carry
59
+ team_create, workflow_states, user_list, stats, upload. Read-only tools carry
60
60
  `readOnlyHint`. Results are compact JSON of the data envelope.
61
61
 
62
62
  Register it with Claude Code:
@@ -73,6 +73,24 @@ Auth resolution is identical to the CLI. Without a credential, tools return
73
73
  an error telling the agent to run `harness login` in a terminal — the
74
74
  server never opens a browser itself.
75
75
 
76
+ ## Repo config
77
+
78
+ A committed `.harness-cli/config.json`, discovered git-style by walking up
79
+ from the working directory, gives every clone of a repo its defaults:
80
+
81
+ ```json
82
+ { "team": "CLI" }
83
+ ```
84
+
85
+ | Field | Effect |
86
+ | --- | --- |
87
+ | `team` | Default team key. Precedence: `--team` > `HARNESS_TEAM` > repo config. |
88
+ | `url` | Target host. `HARNESS_API_URL` wins. Tokens stay host-bound: a stored login only serves the host that minted it, an env `HARNESS_API_TOKEN` refuses to pair with a repo-named host (set `HARNESS_API_URL` explicitly), and before the interactive auto-login opens a browser at a host named only by the repo, the CLI asks once per host (remembered in `~/.config/harness/trusted-hosts.json`). |
89
+ | `org` | Reserved for Harness org-scoping; parsed today, `url` wins meanwhile. |
90
+
91
+ This is config, not state — issues stay server-side. The escript does not
92
+ read this file (TS-CLI-only).
93
+
76
94
  ## Development
77
95
 
78
96
  ```sh
@@ -1250,6 +1250,24 @@ async function labelCreate(ctx, name, color) {
1250
1250
  return { json: response.data, human: `Created label ${clean(response.data["name"])}
1251
1251
  ` };
1252
1252
  }
1253
+ var TEAM_CREATE_KEY = /^[A-Za-z][A-Za-z0-9]*$/;
1254
+ async function teamCreate(ctx, opts) {
1255
+ if (opts.key === void 0 || opts.name === void 0) {
1256
+ throw new UsageError("usage: harness team create --key <key> --name <name>");
1257
+ }
1258
+ if (!TEAM_CREATE_KEY.test(opts.key)) {
1259
+ throw new UsageError(`not a team key: ${opts.key} (expected e.g. ALL \u2014 letters and digits, letter first)`);
1260
+ }
1261
+ const created = await ctx.client.post("/api/v1/teams", { key: opts.key, name: opts.name });
1262
+ const team = created.data;
1263
+ const states = await ctx.client.get(`/api/v1/teams/${str3(team["key"])}/workflow_states`);
1264
+ const human = `Created team ${str3(team["key"])} \u2014 ${clean(team["name"])}
1265
+ ` + table(
1266
+ ["NAME", "TYPE"],
1267
+ states.data.map((state) => [str3(state["name"]), str3(state["type"])])
1268
+ );
1269
+ return { json: { ...team, workflow_states: states.data }, human };
1270
+ }
1253
1271
  async function teamList(ctx) {
1254
1272
  const body = await ctx.client.get("/api/v1/teams");
1255
1273
  const human = body.data.length === 0 ? "No teams.\n" : table(
@@ -1319,18 +1337,119 @@ function deleteCredentials(path2) {
1319
1337
  rmSync2(path2, { force: true });
1320
1338
  }
1321
1339
 
1340
+ // src/core/repo-config.ts
1341
+ import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
1342
+ import { homedir as homedir2 } from "os";
1343
+ import { dirname as dirname3, isAbsolute as isAbsolute2, join as join3, parse } from "path";
1344
+ var CONFIG_RELATIVE_PATH = join3(".harness-cli", "config.json");
1345
+ function findRepoConfig(startDir = process.cwd()) {
1346
+ let dir = startDir;
1347
+ const { root } = parse(startDir);
1348
+ for (; ; ) {
1349
+ const candidate = join3(dir, CONFIG_RELATIVE_PATH);
1350
+ if (existsSync(candidate)) {
1351
+ return readRepoConfig(candidate);
1352
+ }
1353
+ if (dir === root) return { config: null, warnings: [] };
1354
+ dir = dirname3(dir);
1355
+ }
1356
+ }
1357
+ function readRepoConfig(path2) {
1358
+ let parsed;
1359
+ try {
1360
+ parsed = JSON.parse(readFileSync4(path2, "utf8"));
1361
+ } catch (error) {
1362
+ const detail = error instanceof Error ? error.message : String(error);
1363
+ return { config: null, warnings: [`harness: ignoring malformed ${path2} (${detail})`] };
1364
+ }
1365
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1366
+ return { config: null, warnings: [`harness: ignoring ${path2} \u2014 expected a JSON object`] };
1367
+ }
1368
+ const record = parsed;
1369
+ const config = { path: path2 };
1370
+ const warnings = [];
1371
+ for (const field2 of ["team", "url", "org"]) {
1372
+ const value = record[field2];
1373
+ if (value === void 0) continue;
1374
+ if (typeof value === "string" && value.trim() !== "") {
1375
+ config[field2] = value.trim();
1376
+ } else {
1377
+ warnings.push(`harness: ignoring "${field2}" in ${path2} \u2014 expected a non-empty string`);
1378
+ }
1379
+ }
1380
+ return { config, warnings };
1381
+ }
1382
+ function trustedHostsPath(env = process.env) {
1383
+ const xdg = env["XDG_CONFIG_HOME"];
1384
+ const base = xdg !== void 0 && xdg !== "" && isAbsolute2(xdg) ? xdg : join3(homedir2(), ".config");
1385
+ return join3(base, "harness", "trusted-hosts.json");
1386
+ }
1387
+ function readTrustedHosts(env) {
1388
+ try {
1389
+ const parsed = JSON.parse(readFileSync4(trustedHostsPath(env), "utf8"));
1390
+ if (typeof parsed === "object" && parsed !== null && Array.isArray(parsed["hosts"])) {
1391
+ return parsed["hosts"].filter(
1392
+ (host) => typeof host === "string"
1393
+ );
1394
+ }
1395
+ } catch {
1396
+ }
1397
+ return [];
1398
+ }
1399
+ function isHostTrusted(baseUrl, env) {
1400
+ return readTrustedHosts(env).includes(baseUrl);
1401
+ }
1402
+ function trustHost(baseUrl, env) {
1403
+ const hosts = readTrustedHosts(env);
1404
+ if (hosts.includes(baseUrl)) return;
1405
+ const path2 = trustedHostsPath(env);
1406
+ mkdirSync3(dirname3(path2), { recursive: true });
1407
+ writeFileSync3(path2, JSON.stringify({ hosts: [...hosts, baseUrl] }, null, 2) + "\n");
1408
+ }
1409
+
1322
1410
  // src/core/config.ts
1323
- import { homedir as homedir2, tmpdir } from "os";
1324
- import { isAbsolute as isAbsolute2, join as join3 } from "path";
1411
+ import { homedir as homedir3, tmpdir } from "os";
1412
+ import { isAbsolute as isAbsolute3, join as join4 } from "path";
1325
1413
  var DEFAULT_BASE_URL = "https://harness.infra.one";
1326
- function resolveConfig(env = process.env, credentialsPath) {
1414
+ function resolveConfig(env = process.env, credentialsPath, repo) {
1415
+ let warnings = [];
1416
+ if (repo === void 0) {
1417
+ const lookup = findRepoConfig();
1418
+ repo = lookup.config;
1419
+ warnings = lookup.warnings;
1420
+ }
1327
1421
  const path2 = credentialsPath ?? defaultCredentialsPath(env);
1328
1422
  const stored = readCredentials(path2);
1329
1423
  const envUrl = blankToNull(env["HARNESS_API_URL"]);
1330
1424
  const envToken = blankToNull(env["HARNESS_API_TOKEN"]);
1331
- const rawUrl = envUrl ?? (envToken === null && stored !== null ? stored.url : DEFAULT_BASE_URL);
1425
+ const repoUrl = repo?.url?.replace(/\/+$/, "") ?? null;
1426
+ let rawUrl;
1427
+ let hostSource;
1428
+ if (envUrl !== null) {
1429
+ rawUrl = envUrl;
1430
+ hostSource = "env";
1431
+ } else if (envToken !== null) {
1432
+ if (repoUrl !== null && normalize(repoUrl) !== normalize(DEFAULT_BASE_URL)) {
1433
+ return {
1434
+ kind: "error",
1435
+ message: `this repo's config (${repo?.path}) targets ${repoUrl}, but HARNESS_API_TOKEN only travels to the host the environment names \u2014 set HARNESS_API_URL explicitly to pair them`,
1436
+ warnings
1437
+ };
1438
+ }
1439
+ rawUrl = DEFAULT_BASE_URL;
1440
+ hostSource = "default";
1441
+ } else if (repoUrl !== null) {
1442
+ rawUrl = repoUrl;
1443
+ hostSource = "repo-config";
1444
+ } else if (stored !== null) {
1445
+ rawUrl = stored.url;
1446
+ hostSource = "stored-login";
1447
+ } else {
1448
+ rawUrl = DEFAULT_BASE_URL;
1449
+ hostSource = "default";
1450
+ }
1332
1451
  const validated = validateBaseUrl(rawUrl);
1333
- if (!validated.ok) return { kind: "error", message: validated.message };
1452
+ if (!validated.ok) return { kind: "error", message: validated.message, warnings };
1334
1453
  const baseUrl = rawUrl.replace(/\/+$/, "");
1335
1454
  let token;
1336
1455
  if (envToken !== null) {
@@ -1338,7 +1457,9 @@ function resolveConfig(env = process.env, credentialsPath) {
1338
1457
  } else if (stored !== null && normalize(stored.url) !== null && normalize(stored.url) === normalize(baseUrl)) {
1339
1458
  token = stored.token;
1340
1459
  } else {
1341
- return { kind: "needs-login", baseUrl };
1460
+ const result = { kind: "needs-login", baseUrl, hostSource, warnings };
1461
+ if (hostSource === "repo-config" && repo?.path !== void 0) result.repoConfigPath = repo.path;
1462
+ return result;
1342
1463
  }
1343
1464
  return {
1344
1465
  kind: "ok",
@@ -1346,19 +1467,23 @@ function resolveConfig(env = process.env, credentialsPath) {
1346
1467
  baseUrl,
1347
1468
  token,
1348
1469
  actor: blankToNull(env["HARNESS_ACTOR"]),
1349
- team: blankToNull(env["HARNESS_TEAM"]),
1350
- cacheDir: resolveCacheDir(env)
1351
- }
1470
+ // The committed team is the fallback: the environment (and, above it,
1471
+ // the --team flag at the command layer) stays authoritative.
1472
+ team: blankToNull(env["HARNESS_TEAM"]) ?? repo?.team ?? null,
1473
+ cacheDir: resolveCacheDir(env),
1474
+ hostSource
1475
+ },
1476
+ warnings
1352
1477
  };
1353
1478
  }
1354
1479
  function resolveCacheDir(env = process.env) {
1355
- return absolute(env["HARNESS_CACHE_DIR"]) ?? join3(cacheHome(env), "harness", "attachments");
1480
+ return absolute(env["HARNESS_CACHE_DIR"]) ?? join4(cacheHome(env), "harness", "attachments");
1356
1481
  }
1357
1482
  function absolute(value) {
1358
- return value !== void 0 && value !== "" && isAbsolute2(value) ? value : null;
1483
+ return value !== void 0 && value !== "" && isAbsolute3(value) ? value : null;
1359
1484
  }
1360
1485
  function cacheHome(env) {
1361
- return absolute(env["XDG_CACHE_HOME"]) ?? join3(homedir2() || tmpdir(), ".cache");
1486
+ return absolute(env["XDG_CACHE_HOME"]) ?? join4(homedir3() || tmpdir(), ".cache");
1362
1487
  }
1363
1488
  function normalize(url) {
1364
1489
  let parsed;
@@ -1456,6 +1581,7 @@ export {
1456
1581
  linkRm,
1457
1582
  labelList,
1458
1583
  labelCreate,
1584
+ teamCreate,
1459
1585
  teamList,
1460
1586
  workflowStates,
1461
1587
  userList,
@@ -1464,6 +1590,9 @@ export {
1464
1590
  readCredentials,
1465
1591
  writeCredentials,
1466
1592
  deleteCredentials,
1593
+ findRepoConfig,
1594
+ isHostTrusted,
1595
+ trustHost,
1467
1596
  DEFAULT_BASE_URL,
1468
1597
  resolveConfig,
1469
1598
  validateBaseUrl,
package/dist/cli.js CHANGED
@@ -23,6 +23,8 @@ import {
23
23
  docList,
24
24
  docShow,
25
25
  docUpdate,
26
+ findRepoConfig,
27
+ isHostTrusted,
26
28
  json,
27
29
  labelCreate,
28
30
  labelList,
@@ -45,7 +47,9 @@ import {
45
47
  search,
46
48
  show,
47
49
  stats,
50
+ teamCreate,
48
51
  teamList,
52
+ trustHost,
49
53
  update,
50
54
  upload,
51
55
  userList,
@@ -53,9 +57,10 @@ import {
53
57
  version,
54
58
  workflowStates,
55
59
  writeCredentials
56
- } from "./chunk-BGBBEM3L.js";
60
+ } from "./chunk-JMGBMBN6.js";
57
61
 
58
62
  // src/cli.ts
63
+ import { createInterface as createInterface2 } from "readline";
59
64
  import { Command, CommanderError, InvalidArgumentError } from "commander";
60
65
 
61
66
  // src/core/auth.ts
@@ -313,10 +318,23 @@ async function whoami(config, credentialSource) {
313
318
  throw error;
314
319
  }
315
320
  const serverVersion = await client.serverVersion().catch(() => "unknown");
321
+ const hostSourceLabels = {
322
+ env: "HARNESS_API_URL",
323
+ "repo-config": ".harness-cli/config.json",
324
+ "stored-login": "stored login",
325
+ default: "workspace default"
326
+ };
327
+ const via = config.hostSource !== void 0 ? ` (via ${hostSourceLabels[config.hostSource]})` : "";
316
328
  return {
317
329
  ok: true,
318
- json: { url: config.baseUrl, source: credentialSource, verified: true, server_version: serverVersion },
319
- human: `Host: ${config.baseUrl}
330
+ json: {
331
+ url: config.baseUrl,
332
+ source: credentialSource,
333
+ verified: true,
334
+ server_version: serverVersion,
335
+ host_source: config.hostSource
336
+ },
337
+ human: `Host: ${config.baseUrl}${via}
320
338
  Credential: ${credentialSource}
321
339
  Verified: ok (server API ${serverVersion})
322
340
  `
@@ -353,6 +371,15 @@ function defaultOpenBrowser(url) {
353
371
  }
354
372
 
355
373
  // src/cli.ts
374
+ function promptLine(question) {
375
+ const readline = createInterface2({ input: process.stdin, output: process.stderr });
376
+ return new Promise((resolve) => {
377
+ readline.question(question, (answer) => {
378
+ readline.close();
379
+ resolve(answer);
380
+ });
381
+ });
382
+ }
356
383
  function exitWith(code) {
357
384
  const drain = (stream) => new Promise((resolve) => stream.write("", () => resolve()));
358
385
  return Promise.all([drain(process.stdout), drain(process.stderr)]).then(() => process.exit(code));
@@ -376,6 +403,7 @@ function interactive() {
376
403
  }
377
404
  async function withConfig(jsonFlag, mutating, run) {
378
405
  const resolved = resolveConfig();
406
+ for (const warning of resolved.warnings) process.stderr.write(warning + "\n");
379
407
  if (resolved.kind === "error") {
380
408
  process.stderr.write(`harness: ${resolved.message}
381
409
  `);
@@ -386,6 +414,19 @@ async function withConfig(jsonFlag, mutating, run) {
386
414
  process.stderr.write(loginHelp(resolved.baseUrl));
387
415
  return exitWith(2);
388
416
  }
417
+ if (resolved.hostSource === "repo-config" && !isHostTrusted(resolved.baseUrl)) {
418
+ const answer = await promptLine(
419
+ `This repo's config (${resolved.repoConfigPath ?? ".harness-cli/config.json"}) targets ${resolved.baseUrl} \u2014 log in there? [y/N] `
420
+ );
421
+ if (answer.trim().toLowerCase() !== "y") {
422
+ process.stderr.write(
423
+ `harness: not logging in to ${resolved.baseUrl} \u2014 set HARNESS_API_URL to override the repo config
424
+ `
425
+ );
426
+ return exitWith(2);
427
+ }
428
+ trustHost(resolved.baseUrl);
429
+ }
389
430
  const loginResult = await login(resolved.baseUrl, {});
390
431
  if (!loginResult.ok) {
391
432
  process.stderr.write(`harness: ${clean(loginResult.message)}
@@ -640,6 +681,9 @@ var team = program.command("team").description("teams");
640
681
  team.command("list").option("--json", "emit JSON").action(async (opts) => {
641
682
  await withConfig(opts.json === true, false, (ctx) => teamList(ctx));
642
683
  });
684
+ team.command("create").description("create a team, seeded with the default workflow states").option("--key <key>", "identifier prefix for the team's issues, e.g. IOS (required)").option("--name <name>", "display name (required)").option("--json", "emit JSON").action(async (opts) => {
685
+ await withConfig(opts.json === true, true, (ctx) => teamCreate(ctx, opts));
686
+ });
643
687
  program.command("states").description("a team's workflow states").option("--team <key>", "team key (or HARNESS_TEAM)").option("--json", "emit JSON").action(async (opts) => {
644
688
  await withConfig(opts.json === true, false, (ctx) => workflowStates(ctx, opts.team));
645
689
  });
@@ -654,11 +698,18 @@ program.command("upload").description("upload a file; prints markdown to referen
654
698
  });
655
699
  });
656
700
  program.command("mcp").description("run the MCP server over stdio (for agents)").action(async () => {
657
- const { startMcpServer } = await import("./server-YNJDA7IY.js");
701
+ const { startMcpServer } = await import("./server-W4SXUKVP.js");
658
702
  await startMcpServer();
659
703
  });
660
704
  program.command("login").description("sign in via the browser (infra.one session)").option("--url <url>", "host to sign in to").option("--no-browser", "print the URL and prompt for a pasted token instead").option("--timeout <ms>", "how long to wait for the browser callback", integer).option("--json", "emit JSON").action(async (opts) => {
661
- const url = opts.url ?? blankToUndefined(process.env["HARNESS_API_URL"]) ?? storedLoginUrl() ?? DEFAULT_BASE_URL;
705
+ const repoLookup = findRepoConfig();
706
+ for (const warning of repoLookup.warnings) process.stderr.write(warning + "\n");
707
+ const repoUrl = repoLookup.config?.url;
708
+ const url = opts.url ?? blankToUndefined(process.env["HARNESS_API_URL"]) ?? repoUrl ?? storedLoginUrl() ?? DEFAULT_BASE_URL;
709
+ if (url === repoUrl && opts.url === void 0 && blankToUndefined(process.env["HARNESS_API_URL"]) === void 0) {
710
+ process.stderr.write(`harness: host ${url} comes from ${repoLookup.config?.path}
711
+ `);
712
+ }
662
713
  const options = { noBrowser: !opts.browser };
663
714
  if (opts.timeout !== void 0) options.timeoutMs = opts.timeout;
664
715
  await finishAuth(await login(url, options), opts.json === true);
@@ -668,6 +719,7 @@ program.command("logout").description("revoke and forget the stored login").opti
668
719
  });
669
720
  program.command("whoami").description("show the resolved host and credential source").option("--json", "emit JSON").action(async (opts) => {
670
721
  const resolved = resolveConfig();
722
+ for (const warning of resolved.warnings) process.stderr.write(warning + "\n");
671
723
  if (resolved.kind === "error") {
672
724
  process.stderr.write(`harness: ${resolved.message}
673
725
  `);
@@ -32,12 +32,13 @@ import {
32
32
  resolveConfig,
33
33
  show,
34
34
  stats,
35
+ teamCreate,
35
36
  teamList,
36
37
  update,
37
38
  upload,
38
39
  userList,
39
40
  workflowStates
40
- } from "./chunk-BGBBEM3L.js";
41
+ } from "./chunk-JMGBMBN6.js";
41
42
 
42
43
  // src/mcp/server.ts
43
44
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -47,6 +48,7 @@ var NeedsLoginError = class extends Error {
47
48
  };
48
49
  function context() {
49
50
  const resolved = resolveConfig();
51
+ for (const warning of resolved.warnings) process.stderr.write(warning + "\n");
50
52
  if (resolved.kind === "error") throw new NeedsLoginError(resolved.message);
51
53
  if (resolved.kind === "needs-login") {
52
54
  throw new NeedsLoginError(
@@ -383,6 +385,16 @@ var TOOLS = [
383
385
  shape: {},
384
386
  handler: (ctx) => teamList(ctx)
385
387
  },
388
+ {
389
+ name: "team_create",
390
+ description: "Create a team. The key becomes the identifier prefix for issues imported under it (upcased, letters/digits, letter first; IO is reserved). The server seeds the default workflow states, so the team is immediately usable.",
391
+ readOnly: false,
392
+ shape: {
393
+ key: z.string().describe("team key, e.g. IOS"),
394
+ name: z.string().describe("display name")
395
+ },
396
+ handler: (ctx, args) => teamCreate(ctx, { key: args["key"], name: args["name"] })
397
+ },
386
398
  {
387
399
  name: "workflow_states",
388
400
  description: "A team's workflow states (name + type).",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allocator-one/harness-cli",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "CLI and MCP server for Harness, allocator.one's issue tracker",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/spec/openapi.json CHANGED
@@ -1866,6 +1866,53 @@
1866
1866
  },
1867
1867
  "summary": "List teams",
1868
1868
  "tags": []
1869
+ },
1870
+ "post": {
1871
+ "callbacks": {},
1872
+ "deprecated": false,
1873
+ "description": "Requires the `harness:write` scope. Creates a team seeded with the default workflow states (Backlog, Todo, In Progress, Done, Canceled), so it is immediately usable for the full issue lifecycle. The key is upcased, must match `^[A-Z][A-Z0-9]*$`, and `IO` is reserved for the issues Harness mints.",
1874
+ "parameters": [],
1875
+ "requestBody": {
1876
+ "content": {
1877
+ "application/json": {
1878
+ "schema": {
1879
+ "properties": {
1880
+ "key": {
1881
+ "description": "Identifier prefix for issues imported under the team (e.g. ALL). Upcased; create-only.",
1882
+ "type": "string"
1883
+ },
1884
+ "name": {
1885
+ "type": "string"
1886
+ }
1887
+ },
1888
+ "type": "object"
1889
+ }
1890
+ }
1891
+ },
1892
+ "required": true
1893
+ },
1894
+ "responses": {
1895
+ "200": {
1896
+ "description": "Success"
1897
+ },
1898
+ "400": {
1899
+ "description": "Bad request"
1900
+ },
1901
+ "401": {
1902
+ "description": "Missing or invalid token"
1903
+ },
1904
+ "403": {
1905
+ "description": "Token not permitted (org-scoped, non-admin, or missing scope)"
1906
+ },
1907
+ "404": {
1908
+ "description": "Not found"
1909
+ },
1910
+ "422": {
1911
+ "description": "Validation error"
1912
+ }
1913
+ },
1914
+ "summary": "Create a team",
1915
+ "tags": []
1869
1916
  }
1870
1917
  },
1871
1918
  "/api/v1/teams/{team_key}/issues": {