okstra 0.2.0 → 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.
@@ -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
@@ -1,8 +1,12 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import { spawn } from "node:child_process";
3
+ import { homedir } from "node:os";
3
4
  import { join } from "node:path";
4
5
  import { resolvePaths } from "./paths.mjs";
5
6
 
7
+ const CLAUDE_SKILLS_DIR = join(homedir(), ".claude", "skills");
8
+ const REQUIRED_SKILLS = ["okstra-setup", "okstra-run"];
9
+
6
10
  const USAGE = `okstra doctor — diagnose the installed runtime
7
11
 
8
12
  Usage:
@@ -15,6 +19,7 @@ Checks:
15
19
  - okstra_ctl module importable
16
20
  - bash entrypoints present and executable in $HOME/.okstra/bin
17
21
  - agents/ directory exists inside the okstra package
22
+ - canonical skills present at $HOME/.claude/skills/<name>/SKILL.md
18
23
  - version stamp matches package version
19
24
  `;
20
25
 
@@ -104,6 +109,16 @@ export async function run(args) {
104
109
  await check("bin: okstra-codex-exec.sh", () => checkBashEntry(paths.bin, "okstra-codex-exec.sh")),
105
110
  await check("bin: okstra-gemini-exec.sh", () => checkBashEntry(paths.bin, "okstra-gemini-exec.sh")),
106
111
  await check("bin: okstra-central.sh", () => checkBashEntry(paths.bin, "okstra-central.sh")),
112
+ ...(await Promise.all(
113
+ REQUIRED_SKILLS.map((name) =>
114
+ check(`skill: ${name}`, async () => {
115
+ const p = join(CLAUDE_SKILLS_DIR, name, "SKILL.md");
116
+ return (await pathExists(p))
117
+ ? { ok: true, detail: p }
118
+ : { ok: false, detail: `not found: ${p}` };
119
+ }),
120
+ ),
121
+ )),
107
122
  await check("version stamp", async () =>
108
123
  paths.version === paths.package
109
124
  ? { ok: true, detail: paths.version }
package/src/install.mjs CHANGED
@@ -1,9 +1,13 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import { createHash } from "node:crypto";
3
+ import { homedir } from "node:os";
3
4
  import { join, relative, resolve as resolveAbs } from "node:path";
4
5
  import { getPackageRoot } from "./version.mjs";
5
6
  import { resolvePaths } from "./paths.mjs";
6
7
 
8
+ const SKILLS_MANIFEST_REL = "installed-skills.json";
9
+ const CLAUDE_SKILLS_DIR = join(homedir(), ".claude", "skills");
10
+
7
11
  const PYTHON_PACKAGES = ["okstra_project", "okstra_ctl", "okstra_token_usage", "lib"];
8
12
  const BIN_ENTRYPOINTS = [
9
13
  "okstra.sh",
@@ -25,13 +29,16 @@ Usage:
25
29
  okstra install --refresh Re-copy even files that match by hash
26
30
 
27
31
  Effect (copy mode):
28
- ${"$HOME"}/.okstra/lib/python <- packages/okstra/runtime/python
29
- ${"$HOME"}/.okstra/bin <- packages/okstra/runtime/bin
32
+ ${"$HOME"}/.okstra/lib/python <- runtime/python
33
+ ${"$HOME"}/.okstra/bin <- runtime/bin
34
+ ${"$HOME"}/.claude/skills/<name> <- runtime/skills/<name> (per skill)
35
+ ${"$HOME"}/.okstra/installed-skills.json <- manifest of installed skills
30
36
  ${"$HOME"}/.okstra/version <- installed package version stamp
31
37
 
32
38
  Effect (link mode):
33
39
  ${"$HOME"}/.okstra/lib/python/<pkg> -> <repo>/scripts/<pkg> (symlink)
34
40
  ${"$HOME"}/.okstra/bin/<name>.sh -> <repo>/scripts/<name>.sh
41
+ ${"$HOME"}/.claude/skills/<name> -> <repo>/skills/<name> (symlink dir)
35
42
  ${"$HOME"}/.okstra/dev-link <- <repo> path stamp
36
43
  ${"$HOME"}/.okstra/version <- installed package version stamp
37
44
 
@@ -208,6 +215,9 @@ async function installLinkMode(repoPath, paths, opts) {
208
215
  if (!quiet) process.stdout.write(` bin/${name}: ${action}\n`);
209
216
  }
210
217
 
218
+ const skillResult = await installSkillsLink(repoAbs, { dryRun, quiet });
219
+ await writeSkillsManifest(paths.home, skillResult.installed, { dryRun });
220
+
211
221
  if (!dryRun) {
212
222
  await writeFileAtomic(join(paths.home, "dev-link"), repoAbs + "\n", 0o644);
213
223
  await writeFileAtomic(join(paths.home, "version"), paths.package + "\n", 0o644);
@@ -229,6 +239,79 @@ async function fileExists(p) {
229
239
  }
230
240
  }
231
241
 
242
+ async function listSkillDirs(skillsRoot) {
243
+ try {
244
+ const entries = await fs.readdir(skillsRoot, { withFileTypes: true });
245
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
246
+ } catch (err) {
247
+ if (err.code === "ENOENT") return [];
248
+ throw err;
249
+ }
250
+ }
251
+
252
+ async function writeSkillsManifest(home, names, opts) {
253
+ const { dryRun = false } = opts ?? {};
254
+ const data = {
255
+ version: 1,
256
+ installedAt: new Date().toISOString(),
257
+ skills: Array.from(new Set(names)).sort(),
258
+ };
259
+ if (dryRun) {
260
+ process.stdout.write(`[dry-run] write skills manifest: ${data.skills.length} entries\n`);
261
+ return;
262
+ }
263
+ await writeFileAtomic(
264
+ join(home, SKILLS_MANIFEST_REL),
265
+ JSON.stringify(data, null, 2) + "\n",
266
+ 0o644,
267
+ );
268
+ }
269
+
270
+ async function installSkillsCopy(runtimeRoot, opts) {
271
+ const { refresh, dryRun, quiet } = opts;
272
+ const srcRoot = join(runtimeRoot, "skills");
273
+ const names = await listSkillDirs(srcRoot);
274
+ if (names.length === 0) {
275
+ if (!quiet) process.stdout.write(" skills: runtime/skills empty — skipped\n");
276
+ return { installed: [] };
277
+ }
278
+ let copied = 0;
279
+ let skipped = 0;
280
+ for (const name of names) {
281
+ const r = await copyTreeIfChanged(
282
+ join(srcRoot, name),
283
+ join(CLAUDE_SKILLS_DIR, name),
284
+ { refresh, dryRun, mode: 0o644 },
285
+ );
286
+ copied += r.copied;
287
+ skipped += r.skipped;
288
+ }
289
+ if (!quiet) {
290
+ process.stdout.write(
291
+ ` skills: copied=${copied} skipped=${skipped} -> ${CLAUDE_SKILLS_DIR}/ (${names.length} skills)\n`,
292
+ );
293
+ }
294
+ return { installed: names };
295
+ }
296
+
297
+ async function installSkillsLink(repoAbs, opts) {
298
+ const { dryRun, quiet } = opts;
299
+ const srcRoot = join(repoAbs, "skills");
300
+ const names = await listSkillDirs(srcRoot);
301
+ if (names.length === 0) {
302
+ if (!quiet) process.stdout.write(" skills: <repo>/skills missing — skipped\n");
303
+ return { installed: [] };
304
+ }
305
+ if (!dryRun) await fs.mkdir(CLAUDE_SKILLS_DIR, { recursive: true });
306
+ for (const name of names) {
307
+ const src = join(srcRoot, name);
308
+ const dst = join(CLAUDE_SKILLS_DIR, name);
309
+ const action = await ensureSymlink(src, dst, { dryRun });
310
+ if (!quiet) process.stdout.write(` skills/${name}: ${action}\n`);
311
+ }
312
+ return { installed: names };
313
+ }
314
+
232
315
  function parseInstallArgs(args) {
233
316
  const result = {
234
317
  dryRun: false,
@@ -309,6 +392,9 @@ export async function runInstall(args) {
309
392
  );
310
393
  }
311
394
 
395
+ const skillResult = await installSkillsCopy(runtimeRoot, opts);
396
+ await writeSkillsManifest(paths.home, skillResult.installed, { dryRun: opts.dryRun });
397
+
312
398
  if (!opts.dryRun) {
313
399
  await writeFileAtomic(join(paths.home, "version"), paths.package + "\n", 0o644);
314
400
  }
@@ -344,6 +430,9 @@ export async function runEnsureInstalled(args) {
344
430
  }
345
431
  if (!(await dirExists(paths.pythonpath))) reasons.push(`missing ${paths.pythonpath}`);
346
432
  if (!(await dirExists(paths.agents))) reasons.push(`missing agents dir ${paths.agents}`);
433
+ if (!(await fileExists(join(CLAUDE_SKILLS_DIR, "okstra-setup", "SKILL.md")))) {
434
+ reasons.push(`missing ${CLAUDE_SKILLS_DIR}/okstra-setup/SKILL.md`);
435
+ }
347
436
 
348
437
  if (reasons.length === 0) {
349
438
  if (!quiet) process.stdout.write(`okstra runtime OK (package ${paths.package})\n`);
package/src/paths.mjs CHANGED
@@ -14,8 +14,8 @@ Usage:
14
14
  Fields:
15
15
  agents Absolute path to agents/. Default: <workspace>/agents.
16
16
  workspace Directory containing prompts/, templates/, validators/,
17
- agents/. Equals packages/okstra/runtime in copy mode,
18
- or the dev-link repo path in dev mode.
17
+ agents/. Equals the package's runtime/ directory in copy
18
+ mode, or the dev-link repo path in dev mode.
19
19
  pythonpath $HOME/.okstra/lib/python
20
20
  bin $HOME/.okstra/bin
21
21
  home $HOME/.okstra
package/src/uninstall.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { promises as fs } from "node:fs";
2
+ import { homedir } from "node:os";
2
3
  import { join } from "node:path";
3
4
  import { resolvePaths } from "./paths.mjs";
4
5
 
@@ -11,13 +12,35 @@ const BIN_ENTRYPOINTS = [
11
12
  "okstra-error-log.py",
12
13
  ];
13
14
 
14
- const USAGE = `okstra uninstall — remove installed runtime from ~/.okstra
15
+ const FALLBACK_SKILL_NAMES = [
16
+ "okstra-setup",
17
+ "okstra-run",
18
+ "okstra-status",
19
+ "okstra-history",
20
+ "okstra-schedule",
21
+ "okstra-context-loader",
22
+ "okstra-team-contract",
23
+ "okstra-convergence",
24
+ "okstra-report-writer",
25
+ "okstra-report-finder",
26
+ "okstra-time-summary",
27
+ ];
28
+
29
+ const CLAUDE_SKILLS_DIR = join(homedir(), ".claude", "skills");
30
+ const SKILLS_MANIFEST_REL = "installed-skills.json";
31
+
32
+ const USAGE = `okstra uninstall — remove installed runtime from ~/.okstra and ~/.claude/skills
15
33
 
16
34
  Usage:
17
- okstra uninstall Remove lib/, bin/{known entries}, version, dev-link
18
- Preserves user data: recent.jsonl, active.jsonl,
19
- projects/, archive/, state.json, .locks/
20
- okstra uninstall --purge Remove the entire ~/.okstra directory (DESTRUCTIVE)
35
+ okstra uninstall Remove ~/.okstra/{lib, bin/<known>, version,
36
+ dev-link, installed-skills.json} AND
37
+ ~/.claude/skills/<name> for every entry in
38
+ the install manifest (fallback: hard-coded
39
+ okstra-* names). Preserves user data:
40
+ recent.jsonl, active.jsonl, projects/,
41
+ archive/, state.json, .locks/
42
+ okstra uninstall --purge Remove the entire ~/.okstra directory AND
43
+ ~/.claude/skills/<okstra-*> (DESTRUCTIVE).
21
44
  Requires -y or an interactive confirmation
22
45
  okstra uninstall --dry-run Print the plan without touching disk
23
46
  okstra uninstall -y Skip confirmation prompt for --purge
@@ -79,7 +102,9 @@ export async function runUninstall(args) {
79
102
 
80
103
  if (opts.purge) {
81
104
  if (!opts.yes && !opts.dryRun) {
82
- const ok = await promptConfirm(`purge entire ${paths.home}? user data will be lost.`);
105
+ const ok = await promptConfirm(
106
+ `purge entire ${paths.home} AND ~/.claude/skills/{okstra-*,okstra-setup}? user data will be lost.`,
107
+ );
83
108
  if (!ok) {
84
109
  process.stdout.write("aborted.\n");
85
110
  return 1;
@@ -87,6 +112,10 @@ export async function runUninstall(args) {
87
112
  }
88
113
  if (!opts.quiet) process.stdout.write(`purging ${paths.home}\n`);
89
114
  await removePath(paths.home, opts);
115
+ // Skills live outside ~/.okstra — purge those too with the fallback list.
116
+ for (const name of FALLBACK_SKILL_NAMES) {
117
+ await removePath(join(CLAUDE_SKILLS_DIR, name), opts);
118
+ }
90
119
  return 0;
91
120
  }
92
121
 
@@ -112,6 +141,19 @@ export async function runUninstall(args) {
112
141
  }
113
142
  }
114
143
  }
144
+
145
+ // Remove the skills we own. Manifest is authoritative; fall back to the
146
+ // hard-coded okstra-* names if it is missing (e.g. an install from an old
147
+ // version that did not write the manifest).
148
+ const skillNames = await readSkillsManifest(paths.home);
149
+ if (!opts.quiet) {
150
+ process.stdout.write(` skills: removing ${skillNames.length} entries from ${CLAUDE_SKILLS_DIR}\n`);
151
+ }
152
+ for (const name of skillNames) {
153
+ await removePath(join(CLAUDE_SKILLS_DIR, name), opts);
154
+ }
155
+ await removePath(join(paths.home, SKILLS_MANIFEST_REL), opts);
156
+
115
157
  await removePath(join(paths.home, "version"), opts);
116
158
  await removePath(join(paths.home, "dev-link"), opts);
117
159
 
@@ -120,3 +162,14 @@ export async function runUninstall(args) {
120
162
  }
121
163
  return 0;
122
164
  }
165
+
166
+ async function readSkillsManifest(home) {
167
+ try {
168
+ const raw = await fs.readFile(join(home, SKILLS_MANIFEST_REL), "utf8");
169
+ const data = JSON.parse(raw);
170
+ if (Array.isArray(data?.skills)) return data.skills;
171
+ } catch {
172
+ /* fall through */
173
+ }
174
+ return FALLBACK_SKILL_NAMES;
175
+ }