okstra 0.3.0 → 0.5.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.
@@ -0,0 +1,188 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { spawn } from "node:child_process";
3
+ import { join } from "node:path";
4
+ import { resolvePaths } from "./paths.mjs";
5
+
6
+ const USAGE = `okstra check-project — verify that the current project has okstra setup
7
+
8
+ Usage:
9
+ okstra check-project Resolve PROJECT_ROOT from cwd, look for
10
+ .project-docs/okstra/project.json,
11
+ print JSON status to stdout.
12
+ okstra check-project --cwd <dir> Use <dir> as the search starting point
13
+ instead of process cwd.
14
+ okstra check-project --json Same as default (kept for symmetry with
15
+ 'paths --json').
16
+ okstra check-project --quiet Suppress stdout on success; exit code
17
+ alone reports state.
18
+
19
+ Exit codes:
20
+ 0 project.json found, projectId present
21
+ 1 project.json missing or unreadable (project setup not done)
22
+ 2 PROJECT_ROOT could not be resolved from cwd (no project marker
23
+ ancestor; user should pass --cwd or run from within a project)
24
+
25
+ User-facing skills should call this after 'ensure-installed' and refuse
26
+ to proceed if the exit code is non-zero, directing the user to
27
+ '/okstra-setup' first.
28
+ `;
29
+
30
+ function runProcess(cmd, args, env) {
31
+ return new Promise((resolve) => {
32
+ const child = spawn(cmd, args, {
33
+ stdio: ["ignore", "pipe", "pipe"],
34
+ env: { ...process.env, ...env },
35
+ });
36
+ let stdout = "";
37
+ let stderr = "";
38
+ child.stdout.on("data", (b) => (stdout += b.toString()));
39
+ child.stderr.on("data", (b) => (stderr += b.toString()));
40
+ child.on("error", (err) => resolve({ code: -1, stdout, stderr: err.message }));
41
+ child.on("close", (code) => resolve({ code, stdout, stderr }));
42
+ });
43
+ }
44
+
45
+ async function fileExists(p) {
46
+ try {
47
+ await fs.access(p);
48
+ return true;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ function parseArgs(args) {
55
+ const opts = { cwd: process.cwd(), quiet: false, json: true };
56
+ for (let i = 0; i < args.length; i++) {
57
+ const a = args[i];
58
+ if (a === "--quiet" || a === "-q") opts.quiet = true;
59
+ else if (a === "--json") opts.json = true;
60
+ else if (a === "--cwd") {
61
+ const next = args[i + 1];
62
+ if (!next || next.startsWith("--")) throw new Error("--cwd requires a path");
63
+ opts.cwd = next;
64
+ i++;
65
+ } else {
66
+ throw new Error(`unknown argument '${a}'`);
67
+ }
68
+ }
69
+ return opts;
70
+ }
71
+
72
+ function emit(opts, payload) {
73
+ if (opts.quiet) return;
74
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
75
+ }
76
+
77
+ export async function run(args) {
78
+ if (args.includes("--help") || args.includes("-h")) {
79
+ process.stdout.write(USAGE);
80
+ return 0;
81
+ }
82
+
83
+ const opts = parseArgs(args);
84
+ const paths = await resolvePaths();
85
+
86
+ const probe = await runProcess(
87
+ "python3",
88
+ [
89
+ "-c",
90
+ [
91
+ "import json, sys",
92
+ "from okstra_project import resolve_project_root, project_json_path, ResolverError",
93
+ "try:",
94
+ " pr = resolve_project_root(explicit_root='', cwd=sys.argv[1])",
95
+ " print('PROJECT_ROOT', pr)",
96
+ " print('PROJECT_JSON', project_json_path(pr))",
97
+ "except ResolverError as e:",
98
+ " print('RESOLVER_ERROR', e)",
99
+ ].join("\n"),
100
+ opts.cwd,
101
+ ],
102
+ { PYTHONPATH: paths.pythonpath },
103
+ );
104
+
105
+ if (probe.code !== 0) {
106
+ emit(opts, {
107
+ ok: false,
108
+ stage: "python",
109
+ reason: `python invocation failed: ${probe.stderr.trim() || probe.stdout.trim()}`,
110
+ });
111
+ return 1;
112
+ }
113
+
114
+ const lines = probe.stdout.trim().split("\n");
115
+ const tagOf = (key) =>
116
+ lines
117
+ .find((l) => l.startsWith(key + " "))
118
+ ?.slice(key.length + 1)
119
+ .trim() ?? null;
120
+
121
+ const resolverError = tagOf("RESOLVER_ERROR");
122
+ if (resolverError) {
123
+ emit(opts, {
124
+ ok: false,
125
+ stage: "resolve",
126
+ reason: resolverError,
127
+ cwd: opts.cwd,
128
+ });
129
+ return 2;
130
+ }
131
+
132
+ const projectRoot = tagOf("PROJECT_ROOT");
133
+ const projectJsonPath = tagOf("PROJECT_JSON");
134
+ if (!projectRoot || !projectJsonPath) {
135
+ emit(opts, {
136
+ ok: false,
137
+ stage: "parse",
138
+ reason: "could not parse python output",
139
+ raw: probe.stdout,
140
+ });
141
+ return 1;
142
+ }
143
+
144
+ if (!(await fileExists(projectJsonPath))) {
145
+ emit(opts, {
146
+ ok: false,
147
+ stage: "project_json_missing",
148
+ reason: `${projectJsonPath} not found — run /okstra-setup in this project first`,
149
+ projectRoot,
150
+ projectJsonPath,
151
+ });
152
+ return 1;
153
+ }
154
+
155
+ let projectId = null;
156
+ try {
157
+ const data = JSON.parse(await fs.readFile(projectJsonPath, "utf8"));
158
+ projectId = typeof data?.projectId === "string" ? data.projectId : null;
159
+ } catch (err) {
160
+ emit(opts, {
161
+ ok: false,
162
+ stage: "project_json_invalid",
163
+ reason: `failed to parse ${projectJsonPath}: ${err.message}`,
164
+ projectRoot,
165
+ projectJsonPath,
166
+ });
167
+ return 1;
168
+ }
169
+
170
+ if (!projectId) {
171
+ emit(opts, {
172
+ ok: false,
173
+ stage: "project_json_invalid",
174
+ reason: `${projectJsonPath} missing projectId field`,
175
+ projectRoot,
176
+ projectJsonPath,
177
+ });
178
+ return 1;
179
+ }
180
+
181
+ emit(opts, {
182
+ ok: true,
183
+ projectRoot,
184
+ projectJsonPath,
185
+ projectId,
186
+ });
187
+ return 0;
188
+ }
package/src/doctor.mjs CHANGED
@@ -5,7 +5,7 @@ import { join } from "node:path";
5
5
  import { resolvePaths } from "./paths.mjs";
6
6
 
7
7
  const CLAUDE_SKILLS_DIR = join(homedir(), ".claude", "skills");
8
- const REQUIRED_SKILLS = ["setup-okstra", "okstra-run"];
8
+ const REQUIRED_SKILLS = ["okstra-setup", "okstra-run"];
9
9
 
10
10
  const USAGE = `okstra doctor — diagnose the installed runtime
11
11
 
package/src/install.mjs CHANGED
@@ -226,6 +226,13 @@ async function installLinkMode(repoPath, paths, opts) {
226
226
  process.stdout.write(` dev-link stamp: ${repoAbs}\n`);
227
227
  process.stdout.write(` version stamp: ${paths.package}\n`);
228
228
  process.stdout.write("done.\n");
229
+ process.stdout.write(
230
+ "\nNext step: register the current project.\n" +
231
+ " cd <your project> && npx -y okstra@latest setup --project-id <id>\n" +
232
+ " (or run /okstra-setup inside a Claude Code session)\n" +
233
+ "\nTip: to use a bare 'okstra' command instead of npx, run:\n" +
234
+ " npm i -g okstra\n",
235
+ );
229
236
  }
230
237
  return 0;
231
238
  }
@@ -401,6 +408,13 @@ export async function runInstall(args) {
401
408
  if (!opts.quiet) {
402
409
  process.stdout.write(` version stamp: ${paths.package}\n`);
403
410
  process.stdout.write("done.\n");
411
+ process.stdout.write(
412
+ "\nNext step: register the current project.\n" +
413
+ " cd <your project> && npx -y okstra@latest setup --project-id <id>\n" +
414
+ " (or run /okstra-setup inside a Claude Code session)\n" +
415
+ "\nTip: to use a bare 'okstra' command instead of npx, run:\n" +
416
+ " npm i -g okstra\n",
417
+ );
404
418
  }
405
419
  return 0;
406
420
  }
@@ -430,8 +444,8 @@ export async function runEnsureInstalled(args) {
430
444
  }
431
445
  if (!(await dirExists(paths.pythonpath))) reasons.push(`missing ${paths.pythonpath}`);
432
446
  if (!(await dirExists(paths.agents))) reasons.push(`missing agents dir ${paths.agents}`);
433
- if (!(await fileExists(join(CLAUDE_SKILLS_DIR, "setup-okstra", "SKILL.md")))) {
434
- reasons.push(`missing ${CLAUDE_SKILLS_DIR}/setup-okstra/SKILL.md`);
447
+ if (!(await fileExists(join(CLAUDE_SKILLS_DIR, "okstra-setup", "SKILL.md")))) {
448
+ reasons.push(`missing ${CLAUDE_SKILLS_DIR}/okstra-setup/SKILL.md`);
435
449
  }
436
450
 
437
451
  if (reasons.length === 0) {
package/src/setup.mjs ADDED
@@ -0,0 +1,243 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { spawn } from "node:child_process";
3
+ import { createInterface } from "node:readline";
4
+ import { resolvePaths } from "./paths.mjs";
5
+
6
+ const USAGE = `okstra setup — register the current project with okstra
7
+
8
+ Writes <PROJECT_ROOT>/.project-docs/okstra/project.json. This is the
9
+ project-level companion to 'okstra install' (which is machine-level).
10
+ Inside a Claude Code session this is also exposed as the /okstra-setup
11
+ slash command.
12
+
13
+ Usage:
14
+ okstra setup --project-id <id> Register with explicit projectId
15
+ okstra setup Use existing project.json or
16
+ prompt for projectId (TTY only)
17
+ okstra setup --project-root <path> Override PROJECT_ROOT resolution
18
+ okstra setup --yes Skip prompts; require all inputs
19
+ on the command line
20
+
21
+ Behavior:
22
+ - If project.json already exists, the projectId must match (okstra refuses
23
+ to silently rename a project). Delete the file manually if you really
24
+ want to change projectId.
25
+ - If --project-id is omitted and stdin is a TTY, you are prompted.
26
+ - If --project-id is omitted and stdin is not a TTY, the command exits
27
+ with an error (use --project-id for CI / scripts).
28
+
29
+ Exit codes:
30
+ 0 project.json present and valid after the run
31
+ 1 I/O / python failure or projectId mismatch
32
+ 2 PROJECT_ROOT could not be resolved
33
+ `;
34
+
35
+ function runProcess(cmd, args, env) {
36
+ return new Promise((resolve) => {
37
+ const child = spawn(cmd, args, {
38
+ stdio: ["ignore", "pipe", "pipe"],
39
+ env: { ...process.env, ...env },
40
+ });
41
+ let stdout = "";
42
+ let stderr = "";
43
+ child.stdout.on("data", (b) => (stdout += b.toString()));
44
+ child.stderr.on("data", (b) => (stderr += b.toString()));
45
+ child.on("error", (err) => resolve({ code: -1, stdout, stderr: err.message }));
46
+ child.on("close", (code) => resolve({ code, stdout, stderr }));
47
+ });
48
+ }
49
+
50
+ function parseArgs(args) {
51
+ const opts = {
52
+ projectId: null,
53
+ projectRoot: null,
54
+ yes: false,
55
+ };
56
+ for (let i = 0; i < args.length; i++) {
57
+ const a = args[i];
58
+ if (a === "--yes" || a === "-y") opts.yes = true;
59
+ else if (a === "--project-id") {
60
+ const next = args[i + 1];
61
+ if (!next || next.startsWith("--")) throw new Error("--project-id requires a value");
62
+ opts.projectId = next;
63
+ i++;
64
+ } else if (a === "--project-root") {
65
+ const next = args[i + 1];
66
+ if (!next || next.startsWith("--")) throw new Error("--project-root requires a path");
67
+ opts.projectRoot = next;
68
+ i++;
69
+ } else {
70
+ throw new Error(`unknown argument '${a}'`);
71
+ }
72
+ }
73
+ return opts;
74
+ }
75
+
76
+ async function fileExists(p) {
77
+ try {
78
+ await fs.access(p);
79
+ return true;
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ function prompt(question) {
86
+ return new Promise((resolve) => {
87
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
88
+ rl.question(question, (answer) => {
89
+ rl.close();
90
+ resolve(answer.trim());
91
+ });
92
+ });
93
+ }
94
+
95
+ function validateProjectId(id) {
96
+ if (!id) return "project-id is empty";
97
+ if (!/[A-Za-z0-9]/.test(id)) return "project-id must contain at least one alphanumeric character";
98
+ return null;
99
+ }
100
+
101
+ async function resolveProjectRoot(paths, explicit) {
102
+ const probe = await runProcess(
103
+ "python3",
104
+ [
105
+ "-c",
106
+ [
107
+ "import sys",
108
+ "from okstra_project import resolve_project_root, project_json_path, ResolverError",
109
+ "try:",
110
+ " pr = resolve_project_root(explicit_root=sys.argv[1], cwd=sys.argv[2])",
111
+ " print('PROJECT_ROOT', pr)",
112
+ " print('PROJECT_JSON', project_json_path(pr))",
113
+ "except ResolverError as e:",
114
+ " print('RESOLVER_ERROR', e)",
115
+ ].join("\n"),
116
+ explicit || "",
117
+ process.cwd(),
118
+ ],
119
+ { PYTHONPATH: paths.pythonpath },
120
+ );
121
+ if (probe.code !== 0) {
122
+ throw new Error(`python invocation failed: ${probe.stderr.trim() || probe.stdout.trim()}`);
123
+ }
124
+ const lines = probe.stdout.trim().split("\n");
125
+ const tagOf = (key) =>
126
+ lines
127
+ .find((l) => l.startsWith(key + " "))
128
+ ?.slice(key.length + 1)
129
+ .trim() ?? null;
130
+ const resolverError = tagOf("RESOLVER_ERROR");
131
+ if (resolverError) {
132
+ const err = new Error(resolverError);
133
+ err.code = "RESOLVER";
134
+ throw err;
135
+ }
136
+ return {
137
+ projectRoot: tagOf("PROJECT_ROOT"),
138
+ projectJsonPath: tagOf("PROJECT_JSON"),
139
+ };
140
+ }
141
+
142
+ async function upsert(paths, projectRoot, projectId) {
143
+ const probe = await runProcess(
144
+ "python3",
145
+ [
146
+ "-c",
147
+ [
148
+ "import json, sys",
149
+ "from pathlib import Path",
150
+ "from okstra_project import upsert_project_json, ResolverError",
151
+ "try:",
152
+ " result = upsert_project_json(Path(sys.argv[1]), sys.argv[2])",
153
+ " print('OK', json.dumps(result))",
154
+ "except ResolverError as e:",
155
+ " print('ERROR', e)",
156
+ ].join("\n"),
157
+ projectRoot,
158
+ projectId,
159
+ ],
160
+ { PYTHONPATH: paths.pythonpath },
161
+ );
162
+ if (probe.code !== 0) {
163
+ throw new Error(`python invocation failed: ${probe.stderr.trim() || probe.stdout.trim()}`);
164
+ }
165
+ const out = probe.stdout.trim();
166
+ if (out.startsWith("OK ")) {
167
+ return JSON.parse(out.slice(3));
168
+ }
169
+ if (out.startsWith("ERROR ")) {
170
+ throw new Error(out.slice(6));
171
+ }
172
+ throw new Error(`unexpected upsert output: ${out}`);
173
+ }
174
+
175
+ export async function run(args) {
176
+ if (args.includes("--help") || args.includes("-h")) {
177
+ process.stdout.write(USAGE);
178
+ return 0;
179
+ }
180
+
181
+ let opts;
182
+ try {
183
+ opts = parseArgs(args);
184
+ } catch (err) {
185
+ process.stderr.write(`error: ${err.message}\n\n${USAGE}`);
186
+ return 1;
187
+ }
188
+
189
+ const paths = await resolvePaths();
190
+
191
+ let resolved;
192
+ try {
193
+ resolved = await resolveProjectRoot(paths, opts.projectRoot);
194
+ } catch (err) {
195
+ process.stderr.write(`error: could not resolve PROJECT_ROOT: ${err.message}\n`);
196
+ return err.code === "RESOLVER" ? 2 : 1;
197
+ }
198
+ const { projectRoot, projectJsonPath } = resolved;
199
+
200
+ let existing = null;
201
+ if (await fileExists(projectJsonPath)) {
202
+ try {
203
+ existing = JSON.parse(await fs.readFile(projectJsonPath, "utf8"));
204
+ } catch (err) {
205
+ process.stderr.write(`error: failed to parse ${projectJsonPath}: ${err.message}\n`);
206
+ return 1;
207
+ }
208
+ }
209
+
210
+ let projectId = opts.projectId;
211
+ if (!projectId && existing?.projectId) {
212
+ projectId = existing.projectId;
213
+ }
214
+
215
+ if (!projectId) {
216
+ if (opts.yes || !process.stdin.isTTY) {
217
+ process.stderr.write(
218
+ `error: --project-id is required (no existing project.json, not a TTY)\n`,
219
+ );
220
+ return 1;
221
+ }
222
+ process.stderr.write(`PROJECT_ROOT: ${projectRoot}\n`);
223
+ const answer = await prompt("project-id (e.g. INV-1234, fontsninja): ");
224
+ projectId = answer;
225
+ }
226
+
227
+ const invalid = validateProjectId(projectId);
228
+ if (invalid) {
229
+ process.stderr.write(`error: ${invalid}\n`);
230
+ return 1;
231
+ }
232
+
233
+ let result;
234
+ try {
235
+ result = await upsert(paths, projectRoot, projectId);
236
+ } catch (err) {
237
+ process.stderr.write(`error: ${err.message}\n`);
238
+ return 1;
239
+ }
240
+
241
+ process.stdout.write(JSON.stringify({ ok: true, ...result, projectJsonPath }, null, 2) + "\n");
242
+ return 0;
243
+ }
package/src/uninstall.mjs CHANGED
@@ -13,7 +13,7 @@ const BIN_ENTRYPOINTS = [
13
13
  ];
14
14
 
15
15
  const FALLBACK_SKILL_NAMES = [
16
- "setup-okstra",
16
+ "okstra-setup",
17
17
  "okstra-run",
18
18
  "okstra-status",
19
19
  "okstra-history",
@@ -103,7 +103,7 @@ export async function runUninstall(args) {
103
103
  if (opts.purge) {
104
104
  if (!opts.yes && !opts.dryRun) {
105
105
  const ok = await promptConfirm(
106
- `purge entire ${paths.home} AND ~/.claude/skills/{okstra-*,setup-okstra}? user data will be lost.`,
106
+ `purge entire ${paths.home} AND ~/.claude/skills/{okstra-*,okstra-setup}? user data will be lost.`,
107
107
  );
108
108
  if (!ok) {
109
109
  process.stdout.write("aborted.\n");