ascendant-skills 2.1.0 → 2.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.
Files changed (3) hide show
  1. package/bin/cli.mjs +182 -133
  2. package/lib/plan.mjs +10 -59
  3. package/package.json +25 -21
package/bin/cli.mjs CHANGED
@@ -2,165 +2,214 @@
2
2
  /**
3
3
  * `npx ascendant-skills install`: detect coding tools, ask project or
4
4
  * global, then install the ascendant plugin (Claude Code, Codex, OpenCode)
5
- * or skill (everything else) into each. Dependency-free; Node >= 18.
5
+ * or skill (everything else) into each. Built on effect/cli; Node >= 18.
6
6
  *
7
7
  * The command matrix lives in ../lib/plan.mjs; this file only probes the
8
8
  * machine, prompts, and runs the plan.
9
9
  */
10
10
 
11
- import { spawnSync } from "node:child_process";
12
11
  import { existsSync, readFileSync } from "node:fs";
13
12
  import { homedir } from "node:os";
14
13
  import path from "node:path";
15
- import { createInterface } from "node:readline/promises";
16
14
  import { fileURLToPath } from "node:url";
17
- import {
18
- PROVIDERS,
19
- detectProviders,
20
- parseArgs,
21
- parseProviders,
22
- planInstall,
23
- } from "../lib/plan.mjs";
15
+ import { Console, Effect, Option } from "effect";
16
+ import { CliError, Command, Flag, Prompt } from "effect/cli";
17
+ import { ChildProcess, ChildProcessSpawner } from "effect/process";
18
+ import { NodeRuntime, NodeServices } from "@effect/platform-node";
19
+ import { PROVIDERS, SCOPES, detectProviders, parseProviders, planInstall } from "../lib/plan.mjs";
24
20
 
25
21
  const IS_WINDOWS = process.platform === "win32";
26
22
  const PKG = JSON.parse(
27
23
  readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf-8"),
28
24
  );
29
25
 
30
- const HELP = `ascendant-skills v${PKG.version}
26
+ const BIN_NAMES = [...new Set([...PROVIDERS.flatMap((p) => p.bins), "npx"])];
27
+ const HOME_DIRS = [...new Set(PROVIDERS.flatMap((p) => p.homeDirs))];
31
28
 
32
- Install the Ascendant Vedic astrology skill and plugins into your coding tools.
29
+ /** Probe PATH and the home directory once; the plan reads the results synchronously. */
30
+ const detectMachine = Effect.sync(() => {
31
+ const exts = IS_WINDOWS ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
32
+ const dirs = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
33
+ const bins = new Set(
34
+ BIN_NAMES.filter((name) =>
35
+ dirs.some((dir) => exts.some((ext) => existsSync(path.join(dir, name + ext)))),
36
+ ),
37
+ );
38
+ const homeDirs = new Set(HOME_DIRS.filter((rel) => existsSync(path.join(homedir(), rel))));
39
+ return {
40
+ hasBin: (name) => bins.has(name),
41
+ hasHomeDir: (rel) => homeDirs.has(rel),
42
+ };
43
+ });
44
+
45
+ const opencodeMajor = (machine) =>
46
+ Effect.gen(function* () {
47
+ if (!machine.hasBin("opencode")) return undefined;
48
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
49
+ const out = yield* spawner
50
+ .string(ChildProcess.make("opencode", ["--version"], { shell: IS_WINDOWS }))
51
+ .pipe(Effect.orElseSucceed(() => ""));
52
+ const match = /(\d+)\.\d+/.exec(out);
53
+ return match ? Number(match[1]) : undefined;
54
+ });
33
55
 
34
- Usage:
35
- npx ascendant-skills install [options]
56
+ /** Run one plan step with inherited stdio. A missing CLI counts as a failure. */
57
+ const runStep = (step) =>
58
+ Effect.gen(function* () {
59
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
60
+ // Windows CLIs (claude, npx, ...) are .cmd shims that need a shell. Every
61
+ // argument comes from plan.mjs constants, so nothing user-supplied is
62
+ // interpolated into the shell line.
63
+ const code = yield* spawner.exitCode(
64
+ ChildProcess.make(step.command, step.args, {
65
+ shell: IS_WINDOWS,
66
+ stdin: "inherit",
67
+ stdout: "inherit",
68
+ stderr: "inherit",
69
+ }),
70
+ );
71
+ return code === 0;
72
+ }).pipe(Effect.orElseSucceed(() => false));
36
73
 
37
- Options:
38
- --providers=<ids> Comma-separated tools, or "all" (default: detected)
39
- ${PROVIDERS.map((p) => p.id).join(", ")}
40
- --scope=<scope> project | global (default: asks; global with --yes)
41
- -y, --yes Skip prompts and use the defaults
42
- --dry-run Print the commands without running them
43
- -h, --help Show this help
44
- `;
74
+ const userError = (message) => new CliError.UserError({ cause: message });
45
75
 
46
- function hasBin(name) {
47
- const exts = IS_WINDOWS ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
48
- const dirs = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
49
- return dirs.some((dir) => exts.some((ext) => existsSync(path.join(dir, name + ext))));
50
- }
51
-
52
- function hasHomeDir(rel) {
53
- return existsSync(path.join(homedir(), rel));
54
- }
55
-
56
- function opencodeMajor() {
57
- if (!hasBin("opencode")) return undefined;
58
- const out = spawnSync("opencode", ["--version"], { encoding: "utf-8", shell: IS_WINDOWS });
59
- const match = /(\d+)\.\d+/.exec(out.stdout ?? "");
60
- return match ? Number(match[1]) : undefined;
61
- }
62
-
63
- async function ask(rl, question, fallback) {
64
- const answer = (await rl.question(question)).trim();
65
- return answer === "" ? fallback : answer;
66
- }
67
-
68
- async function choose(opts, detected) {
69
- const interactive = !opts.yes && process.stdin.isTTY;
70
- let { providers, scope } = opts;
71
-
72
- if (!interactive) {
73
- return { providers: providers ?? detected, scope: scope ?? "global" };
74
- }
75
-
76
- const rl = createInterface({ input: process.stdin, output: process.stdout });
77
- try {
78
- if (!providers) {
76
+ const parseProvidersOrFail = (raw) =>
77
+ Effect.try({
78
+ try: () => parseProviders(raw),
79
+ catch: (error) => userError(error instanceof Error ? error.message : String(error)),
80
+ });
81
+
82
+ /**
83
+ * Fill in providers and scope. Flags win; anything missing is asked for only
84
+ * on an interactive terminal without `--yes`, otherwise the defaults apply.
85
+ * `providersFlag` is the raw `--providers` value, parsed here.
86
+ */
87
+ const choose = ({ providersFlag, scope, yes, detected }) =>
88
+ Effect.gen(function* () {
89
+ const interactive = !yes && process.stdin.isTTY;
90
+ if (!interactive) {
91
+ const providers =
92
+ providersFlag === undefined ? detected : yield* parseProvidersOrFail(providersFlag);
93
+ return { providers, scope: scope ?? "global" };
94
+ }
95
+
96
+ let providers;
97
+ if (providersFlag === undefined) {
79
98
  const names = detected.map((id) => PROVIDERS.find((p) => p.id === id).name);
80
- console.log(
99
+ yield* Console.log(
81
100
  detected.length > 0 ? `Detected: ${names.join(", ")}` : "No coding tools detected.",
82
101
  );
83
- const raw = await ask(
84
- rl,
85
- `Install for which tools? [${detected.length > 0 ? detected.join(",") : "e.g. claude,cursor"}] `,
86
- detected.join(","),
102
+ const raw = yield* Prompt.run(
103
+ Prompt.String({
104
+ message: `Install for which tools? [${detected.length > 0 ? detected.join(",") : "e.g. claude,cursor"}]`,
105
+ default: detected.join(","),
106
+ }),
87
107
  );
88
- providers = raw === "" ? [] : parseProviders(raw);
108
+ providers = raw.trim() === "" ? [] : yield* parseProvidersOrFail(raw);
109
+ } else {
110
+ providers = yield* parseProvidersOrFail(providersFlag);
89
111
  }
90
- if (!scope) {
91
- const raw = await ask(
92
- rl,
93
- "Install for this project or globally? (project/global) [global] ",
94
- "global",
112
+
113
+ let chosenScope = scope;
114
+ if (chosenScope === undefined) {
115
+ chosenScope = yield* Prompt.run(
116
+ Prompt.Select({
117
+ message: "Install for this project or globally?",
118
+ choices: [
119
+ { title: "Globally", value: "global" },
120
+ { title: "This project", value: "project" },
121
+ ],
122
+ }),
95
123
  );
96
- scope = raw.toLowerCase().startsWith("p") ? "project" : "global";
97
124
  }
98
- } finally {
99
- rl.close();
100
- }
101
- return { providers, scope };
102
- }
103
-
104
- function run(step) {
105
- // Windows CLIs (claude, npx, ...) are .cmd shims that need a shell. Every
106
- // argument comes from plan.mjs constants, so nothing user-supplied is
107
- // interpolated into the shell line.
108
- const result = spawnSync(step.command, step.args, { stdio: "inherit", shell: IS_WINDOWS });
109
- return result.status === 0;
110
- }
111
-
112
- async function main() {
113
- const opts = parseArgs(process.argv.slice(2));
114
- if (opts.help || opts.command === undefined) {
115
- console.log(HELP);
116
- return opts.help ? 0 : 1;
117
- }
118
- if (opts.command !== "install") {
119
- console.error(`Unknown command: ${opts.command}\n\n${HELP}`);
120
- return 1;
121
- }
122
-
123
- const detected = detectProviders({ hasBin, hasHomeDir });
124
- const { providers, scope } = await choose(opts, detected);
125
- if (providers.length === 0) {
126
- console.error(
127
- "Nothing to install. Pass --providers=<ids>, for example --providers=claude,cursor.",
128
- );
129
- return 1;
130
- }
131
-
132
- const { steps, notes } = planInstall({
133
- providers,
134
- scope,
135
- hasBin,
136
- opencodeMajor: opencodeMajor(),
125
+
126
+ return { providers, scope: chosenScope };
137
127
  });
138
- console.log(`\nInstalling ascendant (${scope}) for: ${providers.join(", ")}\n`);
139
-
140
- const failed = [];
141
- for (const step of steps) {
142
- console.log(`→ ${step.label}\n $ ${[step.command, ...step.args].join(" ")}`);
143
- if (opts.dryRun) continue;
144
- if (!run(step)) failed.push(step.label);
145
- }
146
-
147
- for (const note of notes) console.log(`\nNote: ${note}`);
148
- if (failed.length > 0) {
149
- console.error(`\nFailed: ${failed.join("; ")}`);
150
- return 1;
151
- }
152
- if (!opts.dryRun) {
153
- console.log(
154
- "\nDone. Restart your tool, then run /ascendant (Codex: $ascendant) with your birth details.",
155
- );
156
- }
157
- return 0;
158
- }
159
-
160
- main().then(
161
- (code) => process.exit(code),
162
- (error) => {
163
- console.error(error instanceof Error ? error.message : error);
164
- process.exit(1);
128
+
129
+ const install = Command.make(
130
+ "install",
131
+ {
132
+ providers: Flag.optional(
133
+ Flag.String("providers").pipe(
134
+ Flag.withDescription(
135
+ `Comma-separated tools, or "all" (default: detected). Ids: ${PROVIDERS.map((p) => p.id).join(", ")}`,
136
+ ),
137
+ ),
138
+ ),
139
+ scope: Flag.optional(
140
+ Flag.Literals("scope", SCOPES).pipe(
141
+ Flag.withDescription("project | global (default: asks; global with --yes)"),
142
+ ),
143
+ ),
144
+ yes: Flag.Boolean("yes").pipe(
145
+ Flag.withAlias("y"),
146
+ Flag.withDefault(false),
147
+ Flag.withDescription("Skip prompts and use the defaults"),
148
+ ),
149
+ dryRun: Flag.Boolean("dry-run").pipe(
150
+ Flag.withDefault(false),
151
+ Flag.withDescription("Print the commands without running them"),
152
+ ),
165
153
  },
154
+ ({ providers, scope, yes, dryRun }) =>
155
+ Effect.gen(function* () {
156
+ const machine = yield* detectMachine;
157
+ const detected = detectProviders(machine);
158
+ const choice = yield* choose({
159
+ providersFlag: Option.getOrUndefined(providers),
160
+ scope: Option.getOrUndefined(scope),
161
+ yes,
162
+ detected,
163
+ });
164
+ if (choice.providers.length === 0) {
165
+ return yield* Effect.fail(
166
+ userError(
167
+ "Nothing to install. Pass --providers=<ids>, for example --providers=claude,cursor.",
168
+ ),
169
+ );
170
+ }
171
+
172
+ const { steps, notes } = planInstall({
173
+ providers: choice.providers,
174
+ scope: choice.scope,
175
+ hasBin: machine.hasBin,
176
+ opencodeMajor: yield* opencodeMajor(machine),
177
+ });
178
+ yield* Console.log(
179
+ `\nInstalling ascendant (${choice.scope}) for: ${choice.providers.join(", ")}\n`,
180
+ );
181
+
182
+ const failed = [];
183
+ for (const step of steps) {
184
+ yield* Console.log(`→ ${step.label}\n $ ${[step.command, ...step.args].join(" ")}`);
185
+ if (dryRun) continue;
186
+ if (!(yield* runStep(step))) failed.push(step.label);
187
+ }
188
+
189
+ for (const note of notes) yield* Console.log(`\nNote: ${note}`);
190
+ if (failed.length > 0) {
191
+ return yield* Effect.fail(userError(`Failed: ${failed.join("; ")}`));
192
+ }
193
+ if (!dryRun) {
194
+ yield* Console.log(
195
+ "\nDone. Restart your tool, then run /ascendant (Codex: $ascendant) with your birth details.",
196
+ );
197
+ }
198
+ }),
199
+ ).pipe(
200
+ Command.withDescription(
201
+ "Install the Ascendant Vedic astrology skill and plugins into your coding tools.",
202
+ ),
203
+ );
204
+
205
+ const root = Command.make("ascendant-skills").pipe(
206
+ Command.withDescription(
207
+ "Install the Ascendant Vedic astrology skill and plugins into your coding tools.",
208
+ ),
209
+ Command.withSubcommands([install]),
210
+ );
211
+
212
+ Command.runWith(root, { version: PKG.version })(process.argv.slice(2)).pipe(
213
+ Effect.provide(NodeServices.layer),
214
+ NodeRuntime.runMain,
166
215
  );
package/lib/plan.mjs CHANGED
@@ -77,60 +77,14 @@ export const PROVIDERS = [
77
77
  },
78
78
  ];
79
79
 
80
- const PROVIDER_IDS = PROVIDERS.map((p) => p.id);
81
- const SCOPES = ["project", "global"];
80
+ export const PROVIDER_IDS = PROVIDERS.map((p) => p.id);
81
+ export const SCOPES = ["project", "global"];
82
82
 
83
83
  /**
84
- * Parse argv (without node and script). Accepts `--flag=value` and
85
- * `--flag value`. Throws on unknown flags, providers or scopes so a typo in
86
- * a scripted install fails loudly instead of installing nothing.
84
+ * Parse a comma-separated `--providers` value. Throws on an empty value or an
85
+ * unknown id so a typo in a scripted install fails loudly instead of
86
+ * installing nothing. `all` expands to every provider.
87
87
  */
88
- export function parseArgs(argv) {
89
- const opts = {
90
- command: undefined,
91
- providers: undefined,
92
- scope: undefined,
93
- yes: false,
94
- dryRun: false,
95
- help: false,
96
- };
97
- const args = [...argv];
98
- while (args.length > 0) {
99
- const arg = args.shift();
100
- const [flag, inline] = arg.startsWith("--") ? splitFlag(arg) : [arg, undefined];
101
- const value = () => inline ?? args.shift();
102
- switch (flag) {
103
- case "-h":
104
- case "--help":
105
- opts.help = true;
106
- break;
107
- case "-y":
108
- case "--yes":
109
- opts.yes = true;
110
- break;
111
- case "--dry-run":
112
- opts.dryRun = true;
113
- break;
114
- case "--providers":
115
- opts.providers = parseProviders(value());
116
- break;
117
- case "--scope":
118
- opts.scope = parseScope(value());
119
- break;
120
- default:
121
- if (flag.startsWith("-")) throw new Error(`Unknown option: ${flag}`);
122
- if (opts.command !== undefined) throw new Error(`Unexpected argument: ${flag}`);
123
- opts.command = flag;
124
- }
125
- }
126
- return opts;
127
- }
128
-
129
- function splitFlag(arg) {
130
- const eq = arg.indexOf("=");
131
- return eq === -1 ? [arg, undefined] : [arg.slice(0, eq), arg.slice(eq + 1)];
132
- }
133
-
134
88
  export function parseProviders(raw) {
135
89
  if (!raw) throw new Error(`--providers needs a value: ${PROVIDER_IDS.join(",")} or all`);
136
90
  const ids = raw
@@ -147,12 +101,6 @@ export function parseProviders(raw) {
147
101
  return [...new Set(ids)];
148
102
  }
149
103
 
150
- export function parseScope(raw) {
151
- const scope = raw?.trim().toLowerCase();
152
- if (!SCOPES.includes(scope)) throw new Error(`--scope must be one of: ${SCOPES.join(", ")}`);
153
- return scope;
154
- }
155
-
156
104
  /**
157
105
  * Which providers are installed, given probes for binaries and home dirs.
158
106
  * `hasBin(name)` and `hasHomeDir(relPath)` are injected by the CLI.
@@ -229,11 +177,14 @@ export function planInstall({ providers, scope, hasBin, opencodeMajor }) {
229
177
  }
230
178
 
231
179
  if (skillAgents.length > 0) {
180
+ // `npx ascendant-skills` implies npx, but `bunx ascendant-skills` works
181
+ // too and Bun-only machines have no npx to hand off to.
182
+ const [runner, ...runnerArgs] = hasBin("npx") ? ["npx", "-y"] : ["bunx"];
232
183
  steps.push({
233
184
  label: `Agent Skills: ${skillAgents.join(", ")}`,
234
- command: "npx",
185
+ command: runner,
235
186
  args: [
236
- "-y",
187
+ ...runnerArgs,
237
188
  "skills",
238
189
  "add",
239
190
  REPO,
package/package.json CHANGED
@@ -1,37 +1,41 @@
1
1
  {
2
2
  "name": "ascendant-skills",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "One command to install the Ascendant Vedic astrology skill and plugins into Claude Code, Codex, OpenCode, Cursor, Gemini CLI and more.",
5
- "type": "module",
6
- "bin": {
7
- "ascendant-skills": "bin/cli.mjs"
8
- },
9
- "files": [
10
- "bin/",
11
- "lib/",
12
- "README.md"
5
+ "keywords": [
6
+ "agent-skills",
7
+ "astrology",
8
+ "claude-code",
9
+ "codex",
10
+ "opencode",
11
+ "vedic astrology"
13
12
  ],
14
- "engines": {
15
- "node": ">=18"
16
- },
13
+ "homepage": "https://astro-ascendant.vercel.app",
17
14
  "license": "AGPL-3.0-or-later",
18
15
  "author": {
19
16
  "name": "Laxman K R",
20
17
  "email": "krlaxman03@gmail.com",
21
18
  "url": "https://thaletto.vercel.app"
22
19
  },
23
- "homepage": "https://astro-ascendant.vercel.app",
24
20
  "repository": {
25
21
  "type": "git",
26
22
  "url": "https://github.com/astro-ascendant/skills.git",
27
23
  "directory": "packages/ascendant-skills"
28
24
  },
29
- "keywords": [
30
- "agent-skills",
31
- "claude-code",
32
- "codex",
33
- "opencode",
34
- "astrology",
35
- "vedic astrology"
36
- ]
25
+ "bin": {
26
+ "ascendant-skills": "bin/cli.mjs"
27
+ },
28
+ "files": [
29
+ "bin/",
30
+ "lib/",
31
+ "README.md"
32
+ ],
33
+ "type": "module",
34
+ "dependencies": {
35
+ "@effect/platform-node": "4.0.5",
36
+ "effect": "4.0.5"
37
+ },
38
+ "engines": {
39
+ "node": ">=18"
40
+ }
37
41
  }