@nolto/cli 0.3.1 → 0.4.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.
Files changed (3) hide show
  1. package/README.md +21 -1
  2. package/dist/index.js +287 -25
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -32,6 +32,18 @@ nolto init # Interactive setup
32
32
  nolto whoami # Show current auth/config state
33
33
  ```
34
34
 
35
+ ### Repo Binding
36
+
37
+ Bind the current repository to a Nolto project by writing a `nolto.json` at the repo root:
38
+
39
+ ```bash
40
+ nolto link <projectId> # Write nolto.json and commit it to share with your team
41
+ nolto link --show # Show the current binding (path, projectId, source)
42
+ nolto link --unlink # Remove the projectId key from nolto.json
43
+ ```
44
+
45
+ Once `nolto.json` is committed, every command in this repo automatically targets the correct project without needing `--project` or `NOLTO_PROJECT`.
46
+
35
47
  ### Project Management
36
48
 
37
49
  ```bash
@@ -91,7 +103,15 @@ Location: `~/.config/nolto/config.json` (or `$XDG_CONFIG_HOME/nolto/config.json`
91
103
  | `NOLTO_BASE_URL` | Base URL (default: `https://nolto.app`) |
92
104
  | `NOLTO_PROJECT` | Default project ID |
93
105
 
94
- **Precedence**: CLI flags > environment variables > config file > defaults
106
+ ### Repo Binding File
107
+
108
+ `nolto.json` at the repository root pins the project for everyone who clones the repo:
109
+
110
+ ```json
111
+ { "projectId": "00000000-0000-0000-0000-000000000001" }
112
+ ```
113
+
114
+ **Precedence**: CLI flags > environment variables > `nolto.json` (repo binding) > config file > defaults
95
115
 
96
116
  ## Exit Codes
97
117
 
package/dist/index.js CHANGED
@@ -3,11 +3,12 @@
3
3
  // src/index.ts
4
4
  import { createRequire as createRequire2 } from "module";
5
5
  import { fileURLToPath as fileURLToPath2 } from "url";
6
- import path6 from "path";
6
+ import path7 from "path";
7
7
  import { CommanderError } from "commander";
8
8
 
9
9
  // src/config.ts
10
10
  import { readFile, writeFile, mkdir, stat } from "fs/promises";
11
+ import { statSync } from "fs";
11
12
  import os from "os";
12
13
  import path from "path";
13
14
  import { z } from "zod";
@@ -85,6 +86,79 @@ var configSchema = z.object({
85
86
  baseUrl: z.string().url().optional(),
86
87
  defaultProjectId: z.string().uuid().optional()
87
88
  }).strict();
89
+ var repoBindingSchema = z.object({ projectId: z.string().uuid() }).passthrough();
90
+ async function loadRepoBinding(filePath) {
91
+ let raw;
92
+ try {
93
+ raw = await readFile(filePath, "utf8");
94
+ } catch (err) {
95
+ const code = err != null && typeof err === "object" && "code" in err ? err.code : "";
96
+ if (code === "ENOENT") {
97
+ return null;
98
+ }
99
+ throw new CliError(`Cannot read repo binding file: ${filePath}: ${String(err)}`, 2);
100
+ }
101
+ let parsed;
102
+ try {
103
+ parsed = JSON.parse(raw);
104
+ } catch {
105
+ throw new CliError(`Malformed JSON in ${filePath}`, 2);
106
+ }
107
+ const result = repoBindingSchema.safeParse(parsed);
108
+ if (!result.success) {
109
+ const issue = result.error.issues[0];
110
+ const fieldPath = issue?.path.join(".") ?? "projectId";
111
+ throw new CliError(
112
+ `Invalid repo binding at ${filePath}: field "${fieldPath}" \u2014 ${issue?.message ?? "validation failed"}`,
113
+ 2
114
+ );
115
+ }
116
+ return result.data;
117
+ }
118
+ function findNoltoJsonSync(startDir) {
119
+ let current = startDir;
120
+ while (true) {
121
+ const candidate = path.join(current, "nolto.json");
122
+ try {
123
+ statSync(candidate);
124
+ return candidate;
125
+ } catch {
126
+ }
127
+ const parent = path.dirname(current);
128
+ if (parent === current) break;
129
+ current = parent;
130
+ }
131
+ return null;
132
+ }
133
+ function findRepoBindingFile(args) {
134
+ const cpd = args.env["CLAUDE_PROJECT_DIR"];
135
+ const startDir = cpd != null && cpd.length > 0 ? cpd : args.cwd;
136
+ return findNoltoJsonSync(startDir);
137
+ }
138
+ async function writeRepoBinding(root, projectId) {
139
+ const filePath = path.join(root, "nolto.json");
140
+ let existing = {};
141
+ try {
142
+ const raw = await readFile(filePath, "utf8");
143
+ const parsed = JSON.parse(raw);
144
+ if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
145
+ throw new CliError(
146
+ `Cannot update ${filePath}: file contains valid JSON but is not a plain object (got ${Array.isArray(parsed) ? "array" : String(parsed)}). Remove or fix the file before linking.`,
147
+ 2
148
+ );
149
+ }
150
+ existing = parsed;
151
+ } catch (err) {
152
+ if (err instanceof CliError) throw err;
153
+ const code = err != null && typeof err === "object" && "code" in err ? err.code : "";
154
+ if (code !== "ENOENT") {
155
+ }
156
+ }
157
+ const merged = { ...existing, projectId };
158
+ const { chmod } = await import("fs/promises");
159
+ await writeFile(filePath, JSON.stringify(merged, null, 2) + "\n", { mode: 420 });
160
+ await chmod(filePath, 420);
161
+ }
88
162
  function getConfigDir(env) {
89
163
  const xdg = env["XDG_CONFIG_HOME"];
90
164
  const base = xdg != null && xdg.length > 0 ? xdg : path.join(os.homedir(), ".config");
@@ -138,8 +212,24 @@ async function saveConfigFile(filePath, value) {
138
212
  await mkdir(path.dirname(filePath), { recursive: true, mode: 448 });
139
213
  await writeFile(filePath, JSON.stringify(value, null, 2) + "\n", { mode: 384 });
140
214
  }
215
+ function resolveProjectId(args) {
216
+ const { flagProject, envProject, repoBinding, fileProjectId } = args;
217
+ if (flagProject != null && flagProject.length > 0) {
218
+ return { defaultProjectId: flagProject, projectSource: "flag" };
219
+ }
220
+ if (envProject != null && envProject.length > 0) {
221
+ return { defaultProjectId: envProject, projectSource: "env" };
222
+ }
223
+ if (repoBinding != null && repoBinding.projectId.length > 0) {
224
+ return { defaultProjectId: repoBinding.projectId, projectSource: "repo" };
225
+ }
226
+ if (fileProjectId != null && fileProjectId.length > 0) {
227
+ return { defaultProjectId: fileProjectId, projectSource: "file" };
228
+ }
229
+ return { defaultProjectId: void 0, projectSource: "none" };
230
+ }
141
231
  function resolveSettings(args) {
142
- const { flags, env, file } = args;
232
+ const { flags, env, file, repoBinding } = args;
143
233
  let token;
144
234
  let tokenSource = "none";
145
235
  if (flags.token != null && flags.token.length > 0) {
@@ -166,18 +256,12 @@ function resolveSettings(args) {
166
256
  } else {
167
257
  baseUrl = DEFAULT_BASE_URL;
168
258
  }
169
- let defaultProjectId;
170
- let projectSource = "none";
171
- if (flags.project != null && flags.project.length > 0) {
172
- defaultProjectId = flags.project;
173
- projectSource = "flag";
174
- } else if (env["NOLTO_PROJECT"] != null && env["NOLTO_PROJECT"].length > 0) {
175
- defaultProjectId = env["NOLTO_PROJECT"];
176
- projectSource = "env";
177
- } else if (file?.defaultProjectId != null && file.defaultProjectId.length > 0) {
178
- defaultProjectId = file.defaultProjectId;
179
- projectSource = "file";
180
- }
259
+ const { defaultProjectId, projectSource } = resolveProjectId({
260
+ flagProject: flags.project,
261
+ envProject: env["NOLTO_PROJECT"],
262
+ repoBinding,
263
+ fileProjectId: file?.defaultProjectId
264
+ });
181
265
  return {
182
266
  token,
183
267
  baseUrl,
@@ -419,11 +503,11 @@ function createHttpClient(opts) {
419
503
  const { baseUrl, version, token } = opts;
420
504
  const base = baseUrl.replace(/\/+$/, "");
421
505
  return {
422
- async post(path7, body) {
423
- if (!path7.startsWith("/api/")) {
424
- throw new CliError(`HTTP client path must start with /api/, got: ${path7}`, 2);
506
+ async post(path8, body) {
507
+ if (!path8.startsWith("/api/")) {
508
+ throw new CliError(`HTTP client path must start with /api/, got: ${path8}`, 2);
425
509
  }
426
- const url = `${base}${path7}`;
510
+ const url = `${base}${path8}`;
427
511
  const headers = {
428
512
  "Content-Type": "application/json",
429
513
  "User-Agent": `${CLI_USER_AGENT_NAME}/${version}`
@@ -895,6 +979,7 @@ function register3(program, deps) {
895
979
  } catch {
896
980
  }
897
981
  }
982
+ const projectBindingPath = deps.projectBindingPath ?? null;
898
983
  if (mode2 === "json") {
899
984
  printResult(
900
985
  {
@@ -905,17 +990,19 @@ function register3(program, deps) {
905
990
  tokenSource: settings.source.token,
906
991
  defaultProject: settings.defaultProjectId ?? null,
907
992
  defaultProjectSource: settings.source.project,
993
+ projectBindingPath: projectBindingPath ?? null,
908
994
  projects: projectCount ?? null
909
995
  },
910
996
  mode2
911
997
  );
912
998
  } else {
913
999
  const tokenDisplay = settings.token != null ? `${maskToken(settings.token)} (source: ${settings.source.token})` : "(not configured)";
1000
+ const bindingDisplay = settings.source.project === "repo" && projectBindingPath != null ? `${settings.defaultProjectId ?? "none"} (source: repo, binding: ${projectBindingPath})` : `${settings.defaultProjectId ?? "none"} (source: ${settings.source.project})`;
914
1001
  const lines = [
915
1002
  ["baseUrl", `${settings.baseUrl} (source: ${settings.source.baseUrl})`],
916
1003
  ["configPath", configPath],
917
1004
  ["token", tokenDisplay],
918
- ["defaultProject", `${settings.defaultProjectId ?? "none"} (source: ${settings.source.project})`],
1005
+ ["defaultProject", bindingDisplay],
919
1006
  ["projects", projectCount != null ? String(projectCount) : settings.token != null ? "(unavailable)" : "(no token)"]
920
1007
  ];
921
1008
  const maxKey = lines.reduce((m, [k]) => Math.max(m, k.length), 0);
@@ -1439,7 +1526,7 @@ function register7(program, deps) {
1439
1526
  }
1440
1527
 
1441
1528
  // src/queue-file.ts
1442
- import { openSync, closeSync, writeFileSync, unlinkSync, renameSync, statSync } from "fs";
1529
+ import { openSync, closeSync, writeFileSync, unlinkSync, renameSync, statSync as statSync2 } from "fs";
1443
1530
  import { readFile as readFile4, writeFile as writeFile3, mkdir as mkdir3, unlink as unlink2, appendFile } from "fs/promises";
1444
1531
  import path5 from "path";
1445
1532
  import crypto from "crypto";
@@ -1472,7 +1559,7 @@ function findAncestorWithMarker(startDir) {
1472
1559
  }
1473
1560
  function hasMarkerSync(dir, marker) {
1474
1561
  try {
1475
- statSync(path5.join(dir, marker));
1562
+ statSync2(path5.join(dir, marker));
1476
1563
  return true;
1477
1564
  } catch {
1478
1565
  return false;
@@ -1847,6 +1934,170 @@ function register8(program, deps) {
1847
1934
  });
1848
1935
  }
1849
1936
 
1937
+ // src/commands/link.ts
1938
+ import path6 from "path";
1939
+ import { statSync as statSync3 } from "fs";
1940
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1941
+ function resolveStartDir(env, cwd) {
1942
+ const cpd = env["CLAUDE_PROJECT_DIR"];
1943
+ return cpd != null && cpd.length > 0 ? cpd : cwd;
1944
+ }
1945
+ function dirHasGit(dir) {
1946
+ try {
1947
+ statSync3(path6.join(dir, ".git"));
1948
+ return true;
1949
+ } catch {
1950
+ return false;
1951
+ }
1952
+ }
1953
+ function findRepoRoot(startDir, hasGit = dirHasGit) {
1954
+ let current = startDir;
1955
+ while (true) {
1956
+ if (hasGit(current)) {
1957
+ return { root: current, foundGit: true };
1958
+ }
1959
+ const parent = path6.dirname(current);
1960
+ if (parent === current) break;
1961
+ current = parent;
1962
+ }
1963
+ return { root: startDir, foundGit: false };
1964
+ }
1965
+ async function handleShow(deps, projectBindingPath, mode2) {
1966
+ if (projectBindingPath == null) {
1967
+ if (mode2 === "json") {
1968
+ printResult({ bound: false, projectBindingPath: null }, mode2);
1969
+ } else {
1970
+ process.stdout.write("No nolto.json binding found in this directory tree.\n");
1971
+ }
1972
+ return;
1973
+ }
1974
+ const binding = await loadRepoBinding(projectBindingPath).catch((err) => {
1975
+ if (err instanceof CliError) throw err;
1976
+ throw new CliError(`Cannot read binding: ${String(err)}`, 2);
1977
+ });
1978
+ if (mode2 === "json") {
1979
+ printResult({
1980
+ bound: binding != null,
1981
+ projectId: binding?.projectId ?? null,
1982
+ projectBindingPath,
1983
+ source: deps.settings.source.project === "repo" ? "repo" : "file"
1984
+ }, mode2);
1985
+ } else {
1986
+ if (binding == null) {
1987
+ process.stdout.write(`Binding file found at ${projectBindingPath} but could not be read.
1988
+ `);
1989
+ } else {
1990
+ process.stdout.write(`Binding file : ${projectBindingPath}
1991
+ `);
1992
+ process.stdout.write(`projectId : ${binding.projectId}
1993
+ `);
1994
+ const active = deps.settings.source.project === "repo" ? "repo (active)" : "repo (not active \u2014 overridden)";
1995
+ process.stdout.write(`source : ${active}
1996
+ `);
1997
+ }
1998
+ }
1999
+ }
2000
+ async function handleUnlink(projectBindingPath, mode2) {
2001
+ const { readFile: readFile5, writeFile: writeFile4, chmod } = await import("fs/promises");
2002
+ let existing = {};
2003
+ try {
2004
+ const raw = await readFile5(projectBindingPath, "utf8");
2005
+ const parsed = JSON.parse(raw);
2006
+ if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
2007
+ throw new CliError(
2008
+ `Cannot unlink ${projectBindingPath}: file contains valid JSON but is not a plain object (got ${Array.isArray(parsed) ? "array" : String(parsed)}). Remove or fix the file manually.`,
2009
+ 2
2010
+ );
2011
+ }
2012
+ existing = parsed;
2013
+ } catch (err) {
2014
+ if (err instanceof CliError) throw err;
2015
+ throw new CliError(`Cannot read ${projectBindingPath}: ${String(err)}`, 2);
2016
+ }
2017
+ const { projectId: _removed, ...rest } = existing;
2018
+ void _removed;
2019
+ await writeFile4(projectBindingPath, JSON.stringify(rest, null, 2) + "\n", { mode: 420 });
2020
+ await chmod(projectBindingPath, 420);
2021
+ if (mode2 === "json") {
2022
+ printResult({ unlinked: true, projectBindingPath }, mode2);
2023
+ } else {
2024
+ process.stdout.write(`Removed projectId from ${projectBindingPath}.
2025
+ `);
2026
+ }
2027
+ }
2028
+ async function performLink(deps, projectId, mode2) {
2029
+ if (!UUID_RE.test(projectId)) {
2030
+ throw new CliError(
2031
+ `Invalid project ID: "${projectId}". Must be a UUID (e.g. 00000000-0000-0000-0000-000000000001).`,
2032
+ 2
2033
+ );
2034
+ }
2035
+ const startDir = resolveStartDir(process.env, process.cwd());
2036
+ const { root, foundGit } = findRepoRoot(startDir);
2037
+ if (!foundGit) {
2038
+ process.stderr.write(
2039
+ `Warning: no .git directory found above ${startDir}. Writing nolto.json to current directory.
2040
+ `
2041
+ );
2042
+ }
2043
+ if (deps.settings.token != null) {
2044
+ try {
2045
+ const result = await deps.caller.call("list_projects", {});
2046
+ const projects = result?.projects ?? [];
2047
+ if (!projects.some((p) => p.id === projectId)) {
2048
+ process.stderr.write(
2049
+ `Warning: project ${projectId} was not found in your list_projects response.
2050
+ Proceeding anyway \u2014 verify the ID is correct.
2051
+ `
2052
+ );
2053
+ }
2054
+ } catch {
2055
+ process.stderr.write(
2056
+ "Warning: could not verify project membership (offline or token issue). Proceeding anyway.\n"
2057
+ );
2058
+ }
2059
+ }
2060
+ await writeRepoBinding(root, projectId);
2061
+ const writtenPath = path6.join(root, "nolto.json");
2062
+ if (mode2 === "json") {
2063
+ printResult({ linked: true, projectId, projectBindingPath: writtenPath }, mode2);
2064
+ } else {
2065
+ process.stdout.write(
2066
+ `Linked this repo to project ${projectId} (wrote ${writtenPath}).
2067
+ Commit nolto.json to share the binding with your team.
2068
+ `
2069
+ );
2070
+ }
2071
+ }
2072
+ function register9(program, deps) {
2073
+ const cmd = program.command("link [projectId]").description(
2074
+ "Bind this repository to a Nolto project.\nWrites nolto.json at the repo root. Commit it to share the binding with your team.\n\nExamples:\n nolto link <uuid> Write / update nolto.json\n nolto link --show Show the current binding\n nolto link --unlink Remove the projectId from nolto.json"
2075
+ ).option("--show", "Show the current repo binding (path + projectId + source)").option("--unlink", "Remove the projectId key from nolto.json");
2076
+ cmd.action(async (projectId) => {
2077
+ const { output } = deps;
2078
+ const projectBindingPath = deps.projectBindingPath ?? null;
2079
+ const mode2 = output.mode;
2080
+ if (cmd.opts()["show"]) {
2081
+ await handleShow(deps, projectBindingPath, mode2);
2082
+ return;
2083
+ }
2084
+ if (cmd.opts()["unlink"]) {
2085
+ if (projectBindingPath == null) {
2086
+ throw new CliError("No nolto.json found in this directory tree. Nothing to unlink.", 2);
2087
+ }
2088
+ await handleUnlink(projectBindingPath, mode2);
2089
+ return;
2090
+ }
2091
+ if (projectId == null || projectId.trim().length === 0) {
2092
+ throw new CliError(
2093
+ "Usage: nolto link <projectId> (provide a UUID)\nOr use --show to view the current binding, --unlink to remove it.",
2094
+ 2
2095
+ );
2096
+ }
2097
+ await performLink(deps, projectId, mode2);
2098
+ });
2099
+ }
2100
+
1850
2101
  // src/program.ts
1851
2102
  function stripCommanderErrorPrefix(msg) {
1852
2103
  return msg.startsWith("error: ") ? msg.slice("error: ".length) : msg;
@@ -1864,15 +2115,16 @@ function buildProgram(deps) {
1864
2115
  register7(program, deps);
1865
2116
  registerQueue(program, deps);
1866
2117
  register8(program, deps);
2118
+ register9(program, deps);
1867
2119
  return program;
1868
2120
  }
1869
2121
 
1870
2122
  // src/index.ts
1871
- var __dirname2 = path6.dirname(fileURLToPath2(import.meta.url));
2123
+ var __dirname2 = path7.dirname(fileURLToPath2(import.meta.url));
1872
2124
  var require2 = createRequire2(import.meta.url);
1873
2125
  function getVersion() {
1874
2126
  try {
1875
- const pkgPath = path6.resolve(__dirname2, "../package.json");
2127
+ const pkgPath = path7.resolve(__dirname2, "../package.json");
1876
2128
  const pkg = require2(pkgPath);
1877
2129
  return pkg.version ?? "0.0.0";
1878
2130
  } catch {
@@ -1905,10 +2157,20 @@ async function main() {
1905
2157
  process.exit();
1906
2158
  }
1907
2159
  }
2160
+ const projectBindingPath = findRepoBindingFile({ env: process.env, cwd: process.cwd() });
2161
+ const repoBinding = projectBindingPath != null ? await loadRepoBinding(projectBindingPath).catch((err) => {
2162
+ if (err instanceof CliError) {
2163
+ printError(err, mode);
2164
+ process.exitCode = err.exitCode;
2165
+ process.exit();
2166
+ }
2167
+ throw err;
2168
+ }) : null;
1908
2169
  const settings = resolveSettings({
1909
2170
  flags: { token: flagToken, baseUrl: flagBaseUrl, project: flagProject },
1910
2171
  env: process.env,
1911
- file: configFile
2172
+ file: configFile,
2173
+ repoBinding
1912
2174
  });
1913
2175
  const version = getVersion();
1914
2176
  const caller = settings.token != null ? createMcpCaller({ baseUrl: settings.baseUrl, token: settings.token, version }) : {
@@ -1919,7 +2181,7 @@ async function main() {
1919
2181
  );
1920
2182
  }
1921
2183
  };
1922
- const program = buildProgram({ caller, settings, output: { mode }, version, configPath });
2184
+ const program = buildProgram({ caller, settings, output: { mode }, version, configPath, projectBindingPath });
1923
2185
  await program.parseAsync(process.argv);
1924
2186
  }
1925
2187
  function extractFlag(argv, name) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nolto/cli",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "CLI for Nolto — register plans and update progress from your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",