nikos 0.1.0 → 0.1.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/dist/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
2
3
  import { runValidate } from "./commands/validate.js";
3
4
  import { runPlan } from "./commands/plan.js";
4
5
  import { runInit } from "./commands/init.js";
@@ -47,6 +48,7 @@ Commands:
47
48
  release rename <n> Change a release's title or goal (--goal)
48
49
  release move <id> <n> Move a story to another release, retagging its feature
49
50
  release reorder <n> <pos> Move a release and renumber them all
51
+ release remove <n> Remove an empty release and renumber the rest
50
52
  plan new <id> Start the next version of a story's plan
51
53
  clear <id> Check a story is Lean and Executable before building
52
54
  verify setup Set up a Cucumber runner for this repo's stack
@@ -63,6 +65,8 @@ Commands:
63
65
  planning <cmd> Bets, key results and the planning dashboard (nikos-planning pack;
64
66
  run \`nikos planning help\`)
65
67
 
68
+ --version Print the installed version
69
+
66
70
  Docs: https://nikos.tools`;
67
71
  async function main(argv) {
68
72
  const [cmd, ...rest] = argv;
@@ -70,6 +74,10 @@ async function main(argv) {
70
74
  console.log(help);
71
75
  return 0;
72
76
  }
77
+ if (cmd === "--version" || cmd === "-v" || cmd === "version") {
78
+ console.log(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version);
79
+ return 0;
80
+ }
73
81
  const run = commands[cmd];
74
82
  if (!run) {
75
83
  console.error(`Unknown command '${cmd}'.\n\n${help}`);
@@ -44,10 +44,17 @@ export function init(root, opts = {}) {
44
44
  }
45
45
  return { intentDir, created, skipped, validation: validate(intentDir) };
46
46
  }
47
+ const EXAMPLES = { name: '"Hello Todo"', key: "HT", dir: "." };
47
48
  export function runInit(args) {
48
49
  const opt = (n) => {
49
50
  const i = args.indexOf(`--${n}`);
50
- return i >= 0 ? args[i + 1] : undefined;
51
+ if (i < 0)
52
+ return undefined;
53
+ const value = args[i + 1];
54
+ // Without this, a bare --name falls back to the folder name and the key changes silently.
55
+ if (value === undefined || value.startsWith("--") || !value.trim())
56
+ throw new Error(`--${n} needs a value, e.g. --${n} ${EXAMPLES[n] ?? "<value>"}`);
57
+ return value;
51
58
  };
52
59
  try {
53
60
  const r = init(opt("dir") ?? process.cwd(), { name: opt("name"), key: opt("key") });
@@ -36,6 +36,18 @@ function retag(intentDir, moved) {
36
36
  writeFileSync(file, setFeatureTag(text, "release", String(moved.get(id)), id));
37
37
  }
38
38
  }
39
+ /** Number the releases 1, 2, 3… in list order, retagging the stories of any that changed. */
40
+ function renumber(o) {
41
+ const moved = new Map();
42
+ o.releases.items.forEach((item, i) => {
43
+ if (numberOf(item) === i + 1)
44
+ return;
45
+ item.set("number", i + 1);
46
+ for (const id of storiesOf(item))
47
+ moved.set(id, i + 1);
48
+ });
49
+ retag(o.intentDir, moved);
50
+ }
39
51
  function save(o) {
40
52
  writeFileSync(o.mapPath, o.map.toString(yamlOpts));
41
53
  return validate(o.intentDir);
@@ -46,12 +58,19 @@ const cleanTitle = (title) => {
46
58
  throw new Error("A release needs a title");
47
59
  return t;
48
60
  };
61
+ /** Refuse a title another release already has (ignoring case), so two releases can't share a name. */
62
+ function uniqueTitle(o, title, except) {
63
+ const taken = o.releases.items.find((r) => r !== except && String(r.get("title")).trim().toLowerCase() === title.toLowerCase());
64
+ if (taken)
65
+ throw new Error(`Release ${numberOf(taken)} is already called '${String(taken.get("title"))}'`);
66
+ return title;
67
+ }
49
68
  /** Add the next numbered release, with no stories yet. */
50
69
  export function addRelease(input, dir) {
51
70
  const o = open(dir);
52
71
  const number = Math.max(0, ...o.releases.items.map(numberOf)) + 1;
53
72
  const goal = input.goal?.trim();
54
- o.releases.add(o.map.createNode({ number, title: cleanTitle(input.title), ...(goal ? { goal } : {}), stories: [] }));
73
+ o.releases.add(o.map.createNode({ number, title: uniqueTitle(o, cleanTitle(input.title)), ...(goal ? { goal } : {}), stories: [] }));
55
74
  const added = o.releases.items[o.releases.items.length - 1];
56
75
  added.get("stories", true).flow = true;
57
76
  return { number, validation: save(o) };
@@ -63,7 +82,7 @@ export function renameRelease(input, dir) {
63
82
  const o = open(dir);
64
83
  const r = find(o, input.number);
65
84
  if (input.title !== undefined)
66
- r.set("title", cleanTitle(input.title));
85
+ r.set("title", uniqueTitle(o, cleanTitle(input.title), r));
67
86
  if (input.goal !== undefined) {
68
87
  const goal = input.goal.trim();
69
88
  if (!goal)
@@ -98,6 +117,22 @@ export function moveStory(input, dir) {
98
117
  retag(o.intentDir, new Map([[input.id, input.to]]));
99
118
  return { id: input.id, from: fromNumber, to: input.to, validation: save(o) };
100
119
  }
120
+ /** Remove an empty release and renumber the ones after it, retagging their stories. */
121
+ export function removeRelease(input, dir) {
122
+ const o = open(dir);
123
+ const r = find(o, input.number);
124
+ const stories = storiesOf(r);
125
+ if (stories.length)
126
+ throw new Error(`Release ${input.number} still has ${stories.join(", ")}. Move its stories out first with nikos release move`);
127
+ const bets = r.get("bets", true);
128
+ if (isSeq(bets) && bets.items.length)
129
+ throw new Error(`Release ${input.number} delivers bets (${bets.items.map((b) => String(b?.value ?? b)).join(", ")}). Move them to another release first`);
130
+ const title = String(r.get("title"));
131
+ const items = o.releases.items;
132
+ items.splice(items.indexOf(r), 1);
133
+ renumber(o);
134
+ return { number: input.number, title, validation: save(o) };
135
+ }
101
136
  /** Move a release to a new position and renumber them all from 1, retagging every story that moved. */
102
137
  export function reorderRelease(input, dir) {
103
138
  const o = open(dir);
@@ -108,22 +143,15 @@ export function reorderRelease(input, dir) {
108
143
  const items = o.releases.items;
109
144
  items.splice(items.indexOf(r), 1);
110
145
  items.splice(input.to - 1, 0, r);
111
- const moved = new Map();
112
- items.forEach((item, i) => {
113
- if (numberOf(item) === i + 1)
114
- return;
115
- item.set("number", i + 1);
116
- for (const id of storiesOf(item))
117
- moved.set(id, i + 1);
118
- });
119
- retag(o.intentDir, moved);
146
+ renumber(o);
120
147
  return { order: items.map((i) => ({ number: numberOf(i), title: String(i.get("title")) })), validation: save(o) };
121
148
  }
122
149
  const USAGE = `Usage:
123
150
  nikos release add "<title>" [--goal "<goal>"]
124
151
  nikos release rename <n> ["<title>"] [--goal "<goal>"] (--goal "" removes it)
125
152
  nikos release move <story-id> <n>
126
- nikos release reorder <n> <position>`;
153
+ nikos release reorder <n> <position>
154
+ nikos release remove <n> (only an empty release)`;
127
155
  function report(v) {
128
156
  if (v.errors.length)
129
157
  console.log(`\n! intent/ now has ${v.errors.length} ${v.errors.length === 1 ? "problem" : "problems"}; run nikos validate to see them.`);
@@ -157,6 +185,11 @@ export function runRelease(args) {
157
185
  console.log(`Releases are now:\n${r.order.map((x) => ` ${x.number}. ${x.title}`).join("\n")}`);
158
186
  report(r.validation);
159
187
  }
188
+ else if (sub === "remove" && positional[0]) {
189
+ const r = removeRelease({ number: Number(positional[0]) }, dir);
190
+ console.log(`Removed release ${r.number}: ${r.title}`);
191
+ report(r.validation);
192
+ }
160
193
  else {
161
194
  console.error(USAGE);
162
195
  return 1;
package/docs/format.md CHANGED
@@ -150,10 +150,11 @@ keeps comments and leaves the rest of the file as it was:
150
150
  | Command | What it does |
151
151
  | --- | --- |
152
152
  | `nikos step add "<title>" --activity <id>` | Add a step under an activity (`--id` to choose its id) |
153
- | `nikos release add "<title>" [--goal "<goal>"]` | Add the next numbered release, with no stories yet |
153
+ | `nikos release add "<title>" [--goal "<goal>"]` | Add the next numbered release, with no stories yet (titles must be unique) |
154
154
  | `nikos release rename <n> ["<title>"] [--goal "<goal>"]` | Change a release's title or goal (`--goal ""` removes it) |
155
155
  | `nikos release move <story-id> <n>` | Move a story to the end of another release and retag its feature |
156
156
  | `nikos release reorder <n> <position>` | Move a release, renumber them all from 1 and retag every moved story |
157
+ | `nikos release remove <n>` | Remove a release with no stories or bets, renumber the rest and retag their stories |
157
158
 
158
159
  ---
159
160
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nikos",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Spec-driven development for AI coding agents: story maps, Gherkin acceptance criteria and a done gate for Claude Code, Codex and Cursor",
5
5
  "license": "MIT",
6
6
  "author": "Niko Vijay",