@dastyaresocial/ds-cs 0.1.2 → 0.2.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/README.md CHANGED
@@ -13,15 +13,74 @@ DS-CS already speaks two agent protocols — a REST API and an MCP server — bu
13
13
 
14
14
  Skills are that knowledge, in the form agents actually load. This CLI installs them.
15
15
 
16
+ ## The interactive flow
17
+
18
+ Run it with no flags and `ds-cs` asks, in this order:
19
+
20
+ ```
21
+ ? Where should ds-cs install skills?
22
+ ❯ This project committed, shared with your team
23
+ Global (every project) your user directory — OpenCode, Claude Code
24
+
25
+ ? Which agents should read these skills?
26
+ type to filter · space toggle · a all · enter confirm
27
+ ❯ ◉ Select all
28
+ ◉ OpenCode .agents/skills
29
+ ◉ Cursor .agents/skills
30
+ ◉ Codex .agents/skills
31
+ ◉ Claude Code .claude/skills
32
+ ○ Windsurf .windsurf/skills
33
+ … 8 more
34
+
35
+ (15 selected: opencode, claude-code, cursor, codex, …)
36
+
37
+ ? How should the files be shared between agents?
38
+ ❯ Symlink one copy, updates land everywhere
39
+ Copy independent files per agent
40
+
41
+ ? Which skills would you like to install?
42
+ type to filter · space toggle · a all · enter install
43
+ ❯ ◉ Select all
44
+ ○ add-component skills/dev/add-component/
45
+ ○ add-orpc-endpoint skills/dev/add-orpc-endpoint/
46
+ …
47
+ ```
48
+
49
+ Scope first, because it decides which directories exist; then which agents read
50
+ the skills; then how those agents share one copy; then what to install.
51
+
52
+ Both multi-selects open on a **Select all** row — space on it, or `a` anywhere,
53
+ takes everything; pressing it again clears the selection. That is what you want
54
+ on a first run and never again.
55
+
56
+ **Your last choices are remembered** in `~/.config/ds-cs/preferences.json`, so
57
+ the next run preselects them and you can just hit enter three times. Delete the
58
+ file to reset. It lives in your user directory, not the repo — scope and agent
59
+ choice are habits, not project configuration.
60
+
61
+ In the skill picker, typing filters against names, paths **and descriptions**, so
62
+ `webh` finds `webhooks` and `manage-stories` (both mention webhooks). Space
63
+ toggles, so you can take several at once.
64
+
65
+ Without a terminal — CI, a pipe, an agent — nothing prompts. `ds-cs add` prints
66
+ a listing, and flags decide:
67
+
68
+ ```bash
69
+ ds-cs add --global # scope
70
+ ds-cs add brand-config -a opencode # agent (repeatable, or a directory path)
71
+ ds-cs add --copy # independent copies
72
+ ds-cs add -y # accept defaults, no prompts
73
+ ```
74
+
16
75
  ## Commands
17
76
 
18
77
  | Command | What it does |
19
78
  |---|---|
20
- | `ds-cs init` | Create skills directories, optionally write MCP config |
79
+ | `ds-cs init` | Prepare skills directories, optionally write MCP config |
21
80
  | `ds-cs add <skill\|source>` | Install skills from DS-CS or anywhere else |
22
81
  | `ds-cs list` | Show what is installed and where it came from |
23
82
  | `ds-cs update [skill…]` | Re-fetch skills that changed upstream |
24
- | `ds-cs remove <skill…>` | Uninstall from every agent |
83
+ | `ds-cs remove <skill…]` | Uninstall skills from every agent |
25
84
  | `ds-cs search [query]` | Search installed skills |
26
85
  | `ds-cs mcp [agent]` | Print, or write, MCP server config |
27
86
  | `ds-cs login` | Store a channel URL and API key |
@@ -124,10 +183,26 @@ Set `metadata.internal: true` to hide a skill from discovery until `INSTALL_INTE
124
183
  ## Development
125
184
 
126
185
  ```bash
127
- npm test # node:test, no dependencies
186
+ npm test # node:test, no dependencies
187
+ npm run sync-skills # refresh the bundled registry copy
128
188
  npm run dev -- --help
129
189
  ```
130
190
 
191
+ The prompts are hand-rolled keypress handling, which cannot be exercised without
192
+ a terminal — every automated check would otherwise stop at `isInteractive()` and
193
+ test the non-interactive path instead. `test/pty_harness.py` runs the real
194
+ binary under a pty, sends keystrokes, and prints the final screen.
195
+
196
+ It splits the raw stream into repaint frames rather than counting occurrences of
197
+ a menu in it. A prompt redraws by emitting cursor-up and erase sequences, so the
198
+ byte stream contains every frame ever drawn; counting them there would report a
199
+ correctly-repainting prompt as one that duplicates its menu:
200
+
201
+ ```bash
202
+ python3 test/pty_harness.py "ENTER,SPACE,ENTER,ENTER,s,t,o,SPACE,ENTER" \
203
+ node bin/ds-cs.mjs add
204
+ ```
205
+
131
206
  While working inside a DS-CS checkout, `ds-cs add` prefers the local `skills/` directory over the published registry, so edits reinstall immediately. Pass a source path explicitly to bypass that.
132
207
 
133
208
  ## Requirements
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dastyaresocial/ds-cs",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "CLI for Dastyare Social — CS. Install DS-CS skills and third-party agent skills, wire up MCP, and authenticate to a channel.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/skills/README.md CHANGED
@@ -3,11 +3,14 @@
3
3
  First-party skills for [Dastyare Social — CS](https://cs.dastyare.social). Install them with:
4
4
 
5
5
  ```bash
6
- npx @dastyaresocial/ds-cs init
7
- npx @dastyaresocial/ds-cs add # browse everything
8
- npx @dastyaresocial/ds-cs add post-with-media # or install one by name
6
+ npx @dastyaresocial/ds-cs init # project or global? which agents? symlink or copy?
7
+ npx @dastyaresocial/ds-cs add # searchable picker over every skill
9
8
  ```
10
9
 
10
+ Both are interactive and remember your last choices. To skip the prompts, pass a
11
+ skill name (`ds-cs add post-with-media`) or flags (`--global`, `-a opencode`,
12
+ `--copy`, `-y`).
13
+
11
14
  ## What is here
12
15
 
13
16
  ### Working on the codebase — `dev/`
package/src/agents.mjs CHANGED
@@ -14,21 +14,21 @@ import { exists, isDirectory } from "./utils/fs.mjs";
14
14
  * `--agent-dir <path>`.
15
15
  */
16
16
  export const AGENTS = [
17
- { name: "opencode", project: ".agents/skills", global: ".config/opencode/skills", detect: ".opencode" },
18
- { name: "cursor", project: ".agents/skills", global: ".cursor/skills", detect: ".cursor" },
19
- { name: "codex", project: ".agents/skills", global: ".codex/skills", detect: ".codex" },
20
- { name: "claude-code", project: ".claude/skills", global: ".claude/skills", detect: ".claude" },
21
- { name: "windsurf", project: ".windsurf/skills", global: ".codeium/windsurf/skills", detect: ".windsurf" },
22
- { name: "amp", project: ".agents/skills", global: ".config/agents/skills", detect: ".amp" },
23
- { name: "gemini-cli", project: ".agents/skills", global: ".gemini/skills", detect: ".gemini" },
24
- { name: "github-copilot", project: ".agents/skills", global: ".copilot/skills", detect: ".github" },
25
- { name: "goose", project: ".goose/skills", global: ".config/goose/skills", detect: ".goose" },
26
- { name: "devin", project: ".devin/skills", global: ".config/devin/skills", detect: ".devin" },
27
- { name: "kilo-code", project: ".agents/skills", global: ".kilocode/skills", detect: ".kilocode" },
28
- { name: "cline", project: ".agents/skills", global: ".cline/skills", detect: ".cline" },
29
- { name: "roo", project: ".roo/skills", global: ".roo/skills", detect: ".roo" },
30
- { name: "crush", project: ".crush/skills", global: ".config/crush/skills", detect: ".crush" },
31
- { name: "aider-desk", project: ".aider-desk/skills", global: ".aider-desk/skills", detect: ".aider-desk" },
17
+ { name: "opencode", label: "OpenCode", project: ".agents/skills", global: ".config/opencode/skills", detect: ".opencode", globalRoot: ".config/opencode" },
18
+ { name: "cursor", label: "Cursor", project: ".agents/skills", global: ".cursor/skills", detect: ".cursor", globalRoot: ".cursor" },
19
+ { name: "codex", label: "Codex", project: ".agents/skills", global: ".codex/skills", detect: ".codex", globalRoot: ".codex" },
20
+ { name: "claude-code", label: "Claude Code", project: ".claude/skills", global: ".claude/skills", detect: ".claude", globalRoot: ".claude" },
21
+ { name: "windsurf", label: "Windsurf", project: ".windsurf/skills", global: ".codeium/windsurf/skills", detect: ".windsurf", globalRoot: ".codeium" },
22
+ { name: "gemini-cli", label: "Gemini CLI", project: ".agents/skills", global: ".gemini/skills", detect: ".gemini", globalRoot: ".gemini" },
23
+ { name: "github-copilot", label: "GitHub Copilot", project: ".agents/skills", global: ".copilot/skills", detect: ".github", globalRoot: ".copilot" },
24
+ { name: "amp", label: "Amp", project: ".agents/skills", global: ".config/agents/skills", detect: ".amp", globalRoot: ".config/agents" },
25
+ { name: "goose", label: "Goose", project: ".goose/skills", global: ".config/goose/skills", detect: ".goose", globalRoot: ".config/goose" },
26
+ { name: "devin", label: "Devin", project: ".devin/skills", global: ".config/devin/skills", detect: ".devin", globalRoot: ".config/devin" },
27
+ { name: "kilo-code", label: "Kilo Code", project: ".agents/skills", global: ".kilocode/skills", detect: ".kilocode", globalRoot: ".kilocode" },
28
+ { name: "cline", label: "Cline", project: ".agents/skills", global: ".cline/skills", detect: ".cline", globalRoot: ".cline" },
29
+ { name: "roo", label: "Roo Code", project: ".roo/skills", global: ".roo/skills", detect: ".roo", globalRoot: ".roo" },
30
+ { name: "crush", label: "Crush", project: ".crush/skills", global: ".config/crush/skills", detect: ".crush", globalRoot: ".config/crush" },
31
+ { name: "aider-desk", label: "AiderDesk", project: ".aider-desk/skills", global: ".aider-desk/skills", detect: ".aider-desk", globalRoot: ".aider-desk" },
32
32
  ];
33
33
 
34
34
  /**
@@ -1,70 +1,71 @@
1
1
  import path from "node:path";
2
2
 
3
- import {
4
- CANONICAL_AGENT,
5
- dedupeDirectories,
6
- detectAgents,
7
- getAgent,
8
- skillsDirFor,
9
- } from "../agents.mjs";
3
+ import { dedupeDirectories } from "../agents.mjs";
10
4
  import { installSkills } from "../install.mjs";
11
5
  import { lockfilePath } from "../lockfile.mjs";
6
+ import { agentDirectories, resolveInstallTarget } from "../setup.mjs";
12
7
  import { discoverSkills } from "../skills/discover.mjs";
13
- import { toArray } from "../args.mjs";
14
- import { CliError } from "../utils/errors.mjs";
15
- import { cyan, log, table } from "../utils/log.mjs";
8
+ import { resolveSource } from "../registry/sources.mjs";
9
+ import { cyan, dim, log, table } from "../utils/log.mjs";
16
10
  import { isInteractive } from "../utils/prompt.mjs";
17
11
  import { resolveRequest } from "./target.mjs";
18
12
 
19
13
  export async function addCommand(positional, flags, { projectRoot }) {
20
14
  const { source, names } = resolveRequest(positional, flags);
21
15
 
22
- // `ds-cs add` on its own means "show me what there is" — the interactive
23
- // picker when there is a terminal, the plain listing when there is not (CI, a
24
- // pipe, an agent shell). Erroring on a missing TTY would make the documented
25
- // "browse everything" form fail in exactly the places people run it.
26
- if (flags.list || (names.length === 0 && !isInteractive())) {
16
+ // `ds-cs add` on its own means "show me what there is": the searchable picker
17
+ // on a terminal, a plain listing without one. Erroring on a missing TTY would
18
+ // make the documented "browse everything" form fail in exactly the places
19
+ // people run it — CI, a pipe, an agent shell.
20
+ const browsing = flags.list || (names.length === 0 && !isInteractive());
21
+ if (browsing) {
27
22
  await listSource(source, projectRoot);
28
23
  return;
29
24
  }
30
25
 
31
- const global = Boolean(flags.global);
32
- const targets = resolveTargets(flags, { projectRoot, global });
26
+ const target = await resolveInstallTarget(flags, { projectRoot });
27
+ const directories = dedupeDirectories(
28
+ agentDirectories(target.agents, { projectRoot, global: target.global })
29
+ );
33
30
 
34
31
  const { installed, resolved } = await installSkills({
35
32
  source,
36
33
  names,
37
34
  projectRoot,
38
- targets,
39
- global,
40
- copy: Boolean(flags.copy),
41
- yes: Boolean(flags.yes),
35
+ targets: directories,
36
+ global: target.global,
37
+ copy: target.copy,
38
+ interactive: target.interactive,
42
39
  });
43
40
 
44
- log.success(`Installed ${installed.length} skill${installed.length === 1 ? "" : "s"} from ${resolved.describe()}`);
41
+ log.success(
42
+ `Installed ${installed.length} skill${installed.length === 1 ? "" : "s"} from ${resolved.describe()}`
43
+ );
45
44
 
46
45
  for (const entry of installed) {
47
46
  log.plain(` ${cyan(entry.skill.name)} ${entry.skill.description}`);
48
47
  log.plain(` ${display(entry.destination)}`);
49
48
  for (const link of entry.links) {
50
- log.plain(` → ${display(link.target)} (${link.linked ? "linked" : "copied — symlinks unavailable"})`);
49
+ log.plain(
50
+ ` → ${display(link.target)} (${link.linked ? "linked" : "copied — symlinks unavailable"})`
51
+ );
51
52
  }
52
53
  }
53
54
 
54
- log.plain(`\n Recorded in ${display(lockfilePath(projectRoot))}`);
55
+ log.plain("");
56
+ log.plain(
57
+ dim(
58
+ ` Recorded in ${display(lockfilePath(projectRoot))}${target.global ? dim(" (global install)") : ""}`
59
+ )
60
+ );
55
61
  }
56
62
 
57
- /**
58
- * Show paths relative to the current directory, but fall back to absolute when
59
- * the relative form would be a chain of `../` — nobody can place that.
60
- */
61
63
  function display(target) {
62
64
  const relative = path.relative(process.cwd(), target);
63
65
  return relative === "" || relative.startsWith("..") ? target : relative;
64
66
  }
65
67
 
66
68
  async function listSource(sourceSpec, projectRoot) {
67
- const { resolveSource } = await import("../registry/sources.mjs");
68
69
  const resolved = await resolveSource(sourceSpec, { cwd: projectRoot });
69
70
  const skills = (await discoverSkills(resolved.root)).filter(
70
71
  (skill) => process.env.INSTALL_INTERNAL_SKILLS === "1" || !skill.internal
@@ -88,36 +89,6 @@ async function listSource(sourceSpec, projectRoot) {
88
89
  { key: "description", title: "DESCRIPTION", flex: true },
89
90
  ]
90
91
  );
91
- }
92
-
93
- function resolveTargets(flags, { projectRoot, global }) {
94
- const requested = toArray(flags.agent);
95
- if (requested.includes("*")) return [canonicalDir(projectRoot, global)];
96
-
97
- if (requested.length === 0) {
98
- return detectAgents({ projectRoot, global }).then((agents) =>
99
- dedupeDirectories(
100
- agents.map((agent) => skillsDirFor(agent, { projectRoot, global }))
101
- )
102
- );
103
- }
104
-
105
- return requested.map((name) => {
106
- try {
107
- return skillsDirFor(getAgent(name).name, { projectRoot, global });
108
- } catch {
109
- // `--agent` accepts a literal directory too, so an agent this CLI does not
110
- // know about can still be targeted without waiting to be added here.
111
- if (name.startsWith(".") || path.isAbsolute(name)) {
112
- return path.isAbsolute(name) ? name : path.join(projectRoot, name);
113
- }
114
- throw new CliError(`Unknown agent "${name}"`, {
115
- hint: "Run `ds-cs init --list-agents`, or pass a directory starting with \".\" or \"/\".",
116
- });
117
- }
118
- });
119
- }
120
-
121
- function canonicalDir(projectRoot, global) {
122
- return skillsDirFor(CANONICAL_AGENT, { projectRoot, global });
92
+ log.plain("");
93
+ log.plain(dim(" Install one or more: ds-cs add <name> [name…] · browse interactively: ds-cs add"));
123
94
  }
@@ -1,11 +1,10 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
- import { CANONICAL_AGENT, detectAgents, skillsDirFor } from "../agents.mjs";
5
- import { toArray } from "../args.mjs";
6
4
  import { MCP_TARGETS, buildMcpConfig } from "../mcp-config.mjs";
5
+ import { agentDirectories, resolveInstallTarget } from "../setup.mjs";
7
6
  import { exists, ensureDir } from "../utils/fs.mjs";
8
- import { cyan, log } from "../utils/log.mjs";
7
+ import { bold, cyan, dim, log } from "../utils/log.mjs";
9
8
  import { printAgents } from "./list.mjs";
10
9
 
11
10
  const GITIGNORE_BLOCK = `# ds-cs
@@ -17,10 +16,11 @@ const GITIGNORE_BLOCK = `# ds-cs
17
16
  /**
18
17
  * Prepare a project for DS-CS skills.
19
18
  *
20
- * `init` deliberately installs nothing by itself. It creates the directories and
21
- * optionally the MCP wiring, then prints what to run next — a new user who has
22
- * not asked for a specific skill should not silently end up with forty of them
23
- * in their repository.
19
+ * Interactive: asks where to install, which agents read them, and how files are
20
+ * shared. Non-interactive: flags and detection decide, so CI is unchanged.
21
+ *
22
+ * `init` deliberately installs no skills. A user who has not asked for a
23
+ * specific one should not silently end up with forty in their repository.
24
24
  */
25
25
  export async function initCommand(positional, flags, { projectRoot }) {
26
26
  if (flags.listAgents) {
@@ -28,21 +28,21 @@ export async function initCommand(positional, flags, { projectRoot }) {
28
28
  return;
29
29
  }
30
30
 
31
- const global = Boolean(flags.global);
32
- const detected = await detectAgents({ projectRoot, global });
33
- const requested = toArray(flags.agent);
34
- const agents = requested.length > 0 ? requested : detected;
31
+ const target = await resolveInstallTarget(flags, { projectRoot });
32
+ const { global, agents, copy } = target;
35
33
 
36
- for (const agent of agents) {
37
- const directory = skillsDirFor(agent, { projectRoot, global });
34
+ const directories = agentDirectories(agents, { projectRoot, global });
35
+ for (const directory of directories) {
38
36
  await ensureDir(directory);
39
37
  }
40
38
 
41
- // Many agents share one directory (OpenCode, Cursor and Codex all read
42
- // `.agents/skills`), so report each real directory once.
43
- for (const directory of new Set(agents.map((agent) => skillsDirFor(agent, { projectRoot, global })))) {
44
- log.success(`Skills directory ready: ${display(directory)}`);
39
+ const scopeLabel = global ? "global" : "project";
40
+ log.success(`Skills will install to the ${bold(scopeLabel)} scope.`);
41
+ for (const directory of directories) {
42
+ const relative = display(directory);
43
+ log.plain(` ${cyan("→")} ${relative}`);
45
44
  }
45
+ log.plain(dim(` ${copy ? "copied into each agent" : "symlinked from one canonical copy"}`));
46
46
 
47
47
  if (!global) {
48
48
  await explainGitignore(projectRoot);
@@ -60,7 +60,8 @@ export async function initCommand(positional, flags, { projectRoot }) {
60
60
  }
61
61
 
62
62
  function display(target) {
63
- return path.relative(process.cwd(), target) || ".";
63
+ const relative = path.relative(process.cwd(), target);
64
+ return relative === "" || relative.startsWith("..") ? target : relative;
64
65
  }
65
66
 
66
67
  /**
@@ -102,5 +103,3 @@ async function writeMcpConfigs(projectRoot, flags) {
102
103
  log.success(`MCP server "ds-cs" written to ${display(file)}`);
103
104
  }
104
105
  }
105
-
106
- export { CANONICAL_AGENT };
package/src/install.mjs CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  hashDirectory,
13
13
  removeDirectory,
14
14
  } from "./utils/fs.mjs";
15
- import { select } from "./utils/prompt.mjs";
15
+ import { pickSkills } from "./setup.mjs";
16
16
 
17
17
  /**
18
18
  * Install skills from a source into one or more agents.
@@ -30,6 +30,7 @@ export async function installSkills({
30
30
  global = false,
31
31
  copy = false,
32
32
  yes = false,
33
+ interactive = false,
33
34
  preferLocal = true,
34
35
  }) {
35
36
  const resolved = await resolveSource(sourceSpec, { cwd: projectRoot, preferLocal });
@@ -44,7 +45,7 @@ export async function installSkills({
44
45
  });
45
46
  }
46
47
 
47
- const chosen = await chooseSkills(available, names, resolved, { yes });
48
+ const chosen = await chooseSkills(available, names, resolved, { yes, interactive });
48
49
  if (chosen.length === 0) return { installed: [], resolved };
49
50
 
50
51
  const canonicalDir = skillsDirFor(CANONICAL_AGENT, { projectRoot, global });
@@ -90,22 +91,22 @@ function isSameDirectory(source, destination) {
90
91
  return path.resolve(source) === path.resolve(destination);
91
92
  }
92
93
 
93
- async function chooseSkills(available, names, resolved, { yes }) {
94
+ async function chooseSkills(available, names, resolved, { yes, interactive }) {
94
95
  if (names.includes("*")) return available;
95
96
 
96
97
  if (names.length > 0) {
97
98
  return names.map((name) => assertSkillExists(available, name));
98
99
  }
99
100
 
100
- if (yes) return [available[0]];
101
-
102
- const picked = await select(`Which skills from ${resolved.describe()}?`, available.map((skill) => ({
103
- label: skill.name,
104
- value: skill,
105
- description: skill.description,
106
- })));
101
+ // Interactive with no names means "browse": let the user search and take as
102
+ // many as they want. `addCommand` routes the no-TTY case to the plain
103
+ // listing before it ever reaches here.
104
+ if (interactive && !yes) {
105
+ const picked = await pickSkills(available);
106
+ return picked.map((name) => assertSkillExists(available, name));
107
+ }
107
108
 
108
- return picked.map((choice) => choice.value);
109
+ return [available[0]];
109
110
  }
110
111
 
111
112
  /**
@@ -0,0 +1,66 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { exists } from "./utils/fs.mjs";
6
+
7
+ /**
8
+ * User-level preferences: which scope you normally install into, which agents
9
+ * you normally target, and symlink vs copy.
10
+ *
11
+ * Stored per user rather than per project on purpose. Scope and agent choice
12
+ * are habits, not project configuration — writing them into someone's repo
13
+ * would be litter, and a committed "install globally" would quietly change
14
+ * behaviour on every other machine that cloned it.
15
+ */
16
+ function preferencesPath() {
17
+ const base =
18
+ process.env.DS_CS_CONFIG_DIR ??
19
+ (process.env.XDG_CONFIG_HOME
20
+ ? path.join(process.env.XDG_CONFIG_HOME, "ds-cs")
21
+ : path.join(os.homedir(), ".config", "ds-cs"));
22
+ return path.join(base, "preferences.json");
23
+ }
24
+
25
+ export const DEFAULTS = {
26
+ scope: "project",
27
+ method: "symlink",
28
+ agents: ["opencode", "claude-code"],
29
+ };
30
+
31
+ export async function readPreferences() {
32
+ const file = preferencesPath();
33
+ if (!(await exists(file))) return { ...DEFAULTS };
34
+
35
+ try {
36
+ const parsed = JSON.parse(await fs.readFile(file, "utf8"));
37
+ return {
38
+ scope: parsed.scope === "global" ? "global" : "project",
39
+ method: parsed.method === "copy" ? "copy" : "symlink",
40
+ agents: Array.isArray(parsed.agents) && parsed.agents.length > 0 ? parsed.agents : [...DEFAULTS.agents],
41
+ };
42
+ } catch {
43
+ // A corrupt preferences file must never block an install — fall back to
44
+ // defaults rather than making the user hand-edit JSON.
45
+ return { ...DEFAULTS };
46
+ }
47
+ }
48
+
49
+ export async function writePreferences(patch) {
50
+ const file = preferencesPath();
51
+ const current = await readPreferences();
52
+ const next = { ...current, ...patch };
53
+
54
+ try {
55
+ await fs.mkdir(path.dirname(file), { recursive: true });
56
+ await fs.writeFile(file, `${JSON.stringify(next, null, 2)}\n`, "utf8");
57
+ } catch {
58
+ // Preferences are a convenience. Failing to persist them is not worth
59
+ // failing an install over.
60
+ }
61
+ return next;
62
+ }
63
+
64
+ export function preferencesLocation() {
65
+ return preferencesPath();
66
+ }
package/src/setup.mjs ADDED
@@ -0,0 +1,202 @@
1
+ import path from "node:path";
2
+
3
+ import { AGENTS, CANONICAL_AGENT, getAgent, skillsDirFor } from "./agents.mjs";
4
+ import { readPreferences, writePreferences } from "./preferences.mjs";
5
+ import { isInteractive, multiselect, select } from "./utils/prompt.mjs";
6
+
7
+ /**
8
+ * The interactive setup flow, shared by `init` and `add`.
9
+ *
10
+ * Order matters and mirrors what an installer is actually asking: scope first
11
+ * (it decides which directories exist), then which agents to write into (it
12
+ * decides what gets created), then how the files are shared between them.
13
+ */
14
+
15
+ function agentLabel(agent) {
16
+ return AGENTS.find((candidate) => candidate.name === agent)?.label ?? agent;
17
+ }
18
+
19
+ export async function detectInstalledAgents({ projectRoot, global }) {
20
+ const found = [];
21
+ for (const agent of AGENTS) {
22
+ const marker = global
23
+ ? agent.globalRoot
24
+ : path.join(projectRoot, agent.detect);
25
+ if (marker && (await hasPath(marker))) found.push(agent.name);
26
+ }
27
+ return found;
28
+ }
29
+
30
+ async function hasPath(target) {
31
+ const fs = await import("node:fs/promises");
32
+ try {
33
+ await fs.stat(target);
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ /** Choice list for the agent picker, detected agents marked. */
41
+ function agentChoices(detected) {
42
+ return AGENTS.map((agent) => ({
43
+ value: agent.name,
44
+ label: agent.label,
45
+ description: `${detected.includes(agent.name) ? "detected" : ""} ${agent.project}`.trim(),
46
+ }));
47
+ }
48
+
49
+ /**
50
+ * Ask where to install, which agents, and how to share files — unless the
51
+ * caller already decided via flags or there is no terminal.
52
+ *
53
+ * Returns `{ global, agents, copy, interactive }`. When nothing was prompted
54
+ * the flags and detection decide, so CI behaviour is unchanged.
55
+ */
56
+ export async function resolveInstallTarget(flags, { projectRoot }) {
57
+ const preferences = await readPreferences();
58
+ const detected = await detectInstalledAgents({ projectRoot, global: false });
59
+ const interactive = isInteractive();
60
+
61
+ // ---- scope -------------------------------------------------------------
62
+ let global = Boolean(flags.global);
63
+ let scopeAsked = false;
64
+
65
+ if (interactive && !flags.project && flags.global === undefined) {
66
+ scopeAsked = true;
67
+ const [choice] = await select({
68
+ message: "Where should ds-cs install skills?",
69
+ choices: [
70
+ {
71
+ value: "project",
72
+ label: "This project",
73
+ description: "committed, shared with your team",
74
+ },
75
+ {
76
+ value: "global",
77
+ label: "Global (every project)",
78
+ description: `your user directory — ${preferences.agents.map(agentLabel).join(", ") || "all agents"}`,
79
+ },
80
+ ],
81
+ initialIndex: preferences.scope === "global" ? 1 : 0,
82
+ });
83
+ global = choice.value === "global";
84
+ }
85
+
86
+ // ---- agents ------------------------------------------------------------
87
+ let agents;
88
+ const requestedAgents = toArray(flags.agent);
89
+
90
+ if (requestedAgents.length > 0) {
91
+ agents = requestedAgents.includes("*") ? [CANONICAL_AGENT] : requestedAgents;
92
+ } else if (interactive && !flags.yes) {
93
+ // Pre-select whatever we last chose, falling back to what is detected,
94
+ // falling back to the canonical agent. Remembering the last choice is the
95
+ // single biggest time-saver in a prompt you answer on every new project.
96
+ // The canonical agent is not special-cased here: it is a real agent with a
97
+ // real directory, and dropping it would silently deselect it for everyone.
98
+ const seed =
99
+ preferences.agents.length > 0
100
+ ? preferences.agents
101
+ : detected.length > 0
102
+ ? detected
103
+ : [CANONICAL_AGENT];
104
+
105
+ const picked = await multiselect({
106
+ message: "Which agents should read these skills?",
107
+ choices: agentChoices(detected),
108
+ initialSelected: seed,
109
+ hint: "type to filter · space toggle · a all · enter confirm",
110
+ required: true,
111
+ });
112
+ agents = picked.map((choice) => choice.value);
113
+ } else {
114
+ agents = detected.length > 0 ? detected : [CANONICAL_AGENT];
115
+ }
116
+
117
+ agents = [...new Set(agents.map((agent) => (agent === "*" ? CANONICAL_AGENT : agent)))];
118
+
119
+ // ---- install method ----------------------------------------------------
120
+ let copy = Boolean(flags.copy);
121
+
122
+ if (interactive && !flags.yes && flags.copy === undefined) {
123
+ const [choice] = await select({
124
+ message: "How should the files be shared between agents?",
125
+ choices: [
126
+ {
127
+ value: "symlink",
128
+ label: "Symlink",
129
+ description: "one copy, updates land everywhere",
130
+ },
131
+ {
132
+ value: "copy",
133
+ label: "Copy",
134
+ description: "independent files per agent",
135
+ },
136
+ ],
137
+ initialIndex: preferences.method === "copy" ? 1 : 0,
138
+ });
139
+ copy = choice.value === "copy";
140
+ }
141
+
142
+ // Remember the choices only when the user actually made them.
143
+ if (interactive) {
144
+ await writePreferences({
145
+ scope: global ? "global" : "project",
146
+ agents,
147
+ method: copy ? "copy" : "symlink",
148
+ });
149
+ }
150
+
151
+ return { global, agents, copy, interactive, scopeAsked, detected };
152
+ }
153
+
154
+ function toArray(value) {
155
+ if (value === undefined) return [];
156
+ return Array.isArray(value) ? value : [value];
157
+ }
158
+
159
+ /**
160
+ * Searchable skill picker.
161
+ *
162
+ * A numbered single-select is the wrong shape for an installer: you should be
163
+ * able to take three skills at once and filter a long registry by typing.
164
+ *
165
+ * The list stays compact (name + path), but filtering also runs against the
166
+ * full description — which is where the words a user would actually search for
167
+ * live ("webhook", "migrate", "release"), not in a directory name.
168
+ */
169
+ export async function pickSkills(skills) {
170
+ const picked = await multiselect({
171
+ message: "Which skills would you like to install?",
172
+ choices: skills.map((skill) => ({
173
+ value: skill.name,
174
+ label: skill.name,
175
+ description: skill.skillPath.replace(/SKILL\.md$/, ""),
176
+ keywords: `${skill.name} ${skill.skillPath} ${skill.description ?? ""}`,
177
+ })),
178
+ hint: "type to filter · space toggle · a all · enter install",
179
+ required: true,
180
+ });
181
+ return picked.map((choice) => choice.value);
182
+ }
183
+
184
+ /**
185
+ * Turn agent names into concrete skills directories, resolving custom
186
+ * directories the same way the flag parser does.
187
+ */
188
+ export function agentDirectories(agentNames, { projectRoot, global }) {
189
+ const directories = [];
190
+ for (const agent of agentNames) {
191
+ if (agent.startsWith(".") || path.isAbsolute(agent)) {
192
+ directories.push(path.isAbsolute(agent) ? agent : path.join(projectRoot, agent));
193
+ continue;
194
+ }
195
+ try {
196
+ directories.push(skillsDirFor(getAgent(agent).name, { projectRoot, global }));
197
+ } catch (error) {
198
+ throw error;
199
+ }
200
+ }
201
+ return [...new Set(directories)];
202
+ }
@@ -1,69 +1,433 @@
1
- import readline from "node:readline/promises";
1
+ import readline from "node:readline";
2
2
  import { stdin, stdout } from "node:process";
3
3
 
4
4
  import { CliError } from "./errors.mjs";
5
- import { bold, cyan, dim } from "./log.mjs";
5
+ import { bold, cyan, dim, yellow } from "./log.mjs";
6
6
 
7
7
  /**
8
- * Non-interactive contexts (CI, piped stdin, --yes) must never hang waiting for
9
- * a keypress. Every prompt has a documented non-interactive fallback instead.
8
+ * Interactive prompts.
9
+ *
10
+ * Hand-rolled rather than pulled from a prompt library, because the CLI ships
11
+ * with zero runtime dependencies.
12
+ */
13
+
14
+ const KEY = {
15
+ up: "up",
16
+ down: "down",
17
+ space: "space",
18
+ enter: "enter",
19
+ escape: "escape",
20
+ ctrlC: "c",
21
+ backspace: "backspace",
22
+ };
23
+
24
+ /**
25
+ * A repainting region of the terminal.
26
+ *
27
+ * The previous implementation moved the cursor up, cleared a single line, and
28
+ * wrote the menu again — which appended a fresh copy on every keypress and
29
+ * scrolled the real menu off screen. Repainting has to be stateless: each frame
30
+ * is measured, the last one is fully erased, and the new one is written at a
31
+ * known position.
32
+ */
33
+ function createScreen(write) {
34
+ let lastHeight = 0;
35
+
36
+ return {
37
+ paint(lines) {
38
+ if (lastHeight > 0) write(`\x1B[${lastHeight}A`);
39
+ write("\x1B[0J"); // erase from the cursor to the end of the screen
40
+ for (const line of lines) write(`${line}\n`);
41
+ lastHeight = lines.length;
42
+ },
43
+ erase() {
44
+ if (lastHeight === 0) return;
45
+ write(`\x1B[${lastHeight}A`);
46
+ write("\x1B[0J");
47
+ lastHeight = 0;
48
+ },
49
+ };
50
+ }
51
+
52
+ function readKeys() {
53
+ readline.emitKeypressEvents(stdin);
54
+ if (stdin.isTTY) stdin.setRawMode(true);
55
+ return () => {
56
+ if (stdin.isTTY) stdin.setRawMode(false);
57
+ stdin.pause();
58
+ };
59
+ }
60
+
61
+ function keyOf(key) {
62
+ if (!key) return null;
63
+ switch (key.name) {
64
+ case "up":
65
+ return KEY.up;
66
+ case "k":
67
+ return KEY.up;
68
+ case "down":
69
+ return KEY.down;
70
+ case "j":
71
+ return KEY.down;
72
+ case "space":
73
+ return KEY.space;
74
+ case "return":
75
+ case "enter":
76
+ return KEY.enter;
77
+ case "escape":
78
+ return KEY.escape;
79
+ case "backspace":
80
+ return KEY.backspace;
81
+ default:
82
+ break;
83
+ }
84
+ if (key.ctrl && key.name === KEY.ctrlC) return KEY.ctrlC;
85
+ return null;
86
+ }
87
+
88
+ /**
89
+ * True when we can actually prompt.
90
+ *
91
+ * CI, a pipe, and an agent's shell are all non-interactive. Every prompt below
92
+ * has a documented non-interactive fallback instead of hanging.
10
93
  */
11
94
  export function isInteractive() {
12
95
  return Boolean(stdin.isTTY && stdout.isTTY) && process.env.CI !== "true";
13
96
  }
14
97
 
15
- async function question(prompt) {
16
- const rl = readline.createInterface({ input: stdin, output: stdout });
17
- try {
18
- return (await rl.question(prompt)).trim();
19
- } finally {
20
- rl.close();
21
- }
98
+ function stripAnsi(text) {
99
+ return text.replace(/\u001B\[[0-9;]*m/g, "");
22
100
  }
23
101
 
24
- export async function confirm(message, { defaultYes = true } = {}) {
25
- if (!isInteractive()) return defaultYes;
26
- const suffix = defaultYes ? "[Y/n]" : "[y/N]";
27
- const answer = (await question(`${message} ${dim(suffix)} `)).toLowerCase();
28
- if (answer === "") return defaultYes;
29
- return answer === "y" || answer === "yes";
102
+ function visibleWidth(text) {
103
+ return stripAnsi(text).length;
30
104
  }
31
105
 
32
- export async function promptText(message, fallback = "") {
33
- if (!isInteractive()) return fallback;
34
- const suffix = fallback ? dim(` (${fallback})`) : "";
35
- const answer = await question(`${message}${suffix} `);
36
- return answer || fallback;
106
+ function pad(text, width) {
107
+ const gap = width - visibleWidth(text);
108
+ return gap > 0 ? text + " ".repeat(gap) : text;
109
+ }
110
+
111
+ function termWidth() {
112
+ return stdout.columns || 80;
113
+ }
114
+
115
+ function termRows() {
116
+ return stdout.rows || 24;
37
117
  }
38
118
 
39
119
  /**
40
- * Numbered single-select. Returning an empty list means "nothing chosen", which
41
- * callers treat as a clean no-op rather than an error.
120
+ * Search-and-multi-select over a list of choices.
121
+ *
122
+ * The prompt an installer lives or dies by: typing filters, space toggles,
123
+ * enter confirms. A leading "Select all" row — and the `a` shortcut — make the
124
+ * whole registry selectable without fifteen space presses, which is what you
125
+ * want on a first run and never again.
42
126
  */
43
- export async function select(message, choices) {
127
+ export function multiselect({ message, choices, initialSelected = [], hint, required = false }) {
128
+ if (!isInteractive()) {
129
+ throw new CliError(`"${message}" needs an interactive terminal`, {
130
+ hint: "Pass the choice explicitly instead, e.g. --skill <name> or --agent <name>.",
131
+ });
132
+ }
133
+ if (choices.length === 0) return [];
134
+
135
+ const ALL = Symbol("select-all");
136
+ const selected = new Set(
137
+ initialSelected.filter((value) => choices.some((choice) => choice.value === value))
138
+ );
139
+
140
+ let cursor = 0;
141
+ let filter = "";
142
+ let offset = 0;
143
+ let status = "";
144
+
145
+ const write = (line) => stdout.write(line);
146
+ const screen = createScreen(write);
147
+ const stop = readKeys();
148
+ const pageSize = Math.min(8, Math.max(3, termRows() - 9));
149
+
150
+ const visibleChoices = () =>
151
+ filter === ""
152
+ ? choices
153
+ : choices.filter((choice) =>
154
+ // `keywords` carries extra searchable text — a skill's full
155
+ // description, say — without widening what the list displays.
156
+ (choice.keywords ?? `${choice.label} ${choice.description ?? ""}`)
157
+ .toLowerCase()
158
+ .includes(filter.toLowerCase())
159
+ );
160
+
161
+ const rows = () => [
162
+ { value: ALL, label: "Select all" },
163
+ ...visibleChoices().map((choice) => ({ value: choice.value, label: choice.label, choice })),
164
+ ];
165
+
166
+ const paint = () => {
167
+ const all = rows();
168
+ if (cursor >= all.length) cursor = all.length - 1;
169
+ if (cursor < 0) cursor = 0;
170
+ if (cursor < offset) offset = cursor;
171
+ if (cursor >= offset + pageSize) offset = cursor - pageSize + 1;
172
+
173
+ const window = all.slice(offset, offset + pageSize);
174
+ const labelWidth = Math.min(
175
+ 28,
176
+ Math.max(0, ...all.map((row) => visibleWidth(row.label)))
177
+ );
178
+ const pathWidth = Math.max(
179
+ 0,
180
+ ...all.map((row) => visibleWidth(row.choice?.description ?? ""))
181
+ );
182
+ const lines = [];
183
+ lines.push(`${bold("?")} ${bold(message)}`);
184
+ if (hint) lines.push(` ${dim(hint)}`);
185
+ lines.push(filter === "" ? "" : ` ${dim("filter:")} ${filter}${cyan("▌")}`);
186
+ if (lines[lines.length - 1] === "") lines.pop();
187
+
188
+ for (const row of window) {
189
+ const index = offset + window.indexOf(row);
190
+ const isCursor = index === cursor;
191
+
192
+ if (row.value === ALL) {
193
+ const every = visibleChoices().length > 0 && visibleChoices().every((c) => selected.has(c.value));
194
+ const marker = every ? cyan("◉") : dim("○");
195
+ lines.push(
196
+ ` ${isCursor ? cyan("❯") : " "} ${marker} ${isCursor ? bold("Select all") : "Select all"}`
197
+ );
198
+ continue;
199
+ }
200
+
201
+ const isSelected = selected.has(row.value);
202
+ const marker = isSelected ? cyan("◉") : dim("○");
203
+ const label = isCursor ? bold(pad(row.label, labelWidth)) : pad(row.label, labelWidth);
204
+ const path = row.choice?.description ? dim(` ${row.choice.description}`) : "";
205
+ lines.push(
206
+ ` ${isCursor ? cyan("❯") : " "} ${marker} ${label}${path}`.slice(0, termWidth()).padEnd(
207
+ Math.min(termWidth(), labelWidth + pathWidth + 6)
208
+ )
209
+ );
210
+ }
211
+
212
+ if (all.length > window.length) {
213
+ lines.push(dim(` … ${all.length - window.length} more`));
214
+ }
215
+ lines.push("");
216
+ lines.push(
217
+ status ||
218
+ dim(
219
+ selected.size === 0
220
+ ? "(none selected)"
221
+ : `(${selected.size} selected: ${[...selected].slice(0, 4).join(", ")}${selected.size > 4 ? ", …" : ""})`
222
+ )
223
+ );
224
+ screen.paint(lines);
225
+ };
226
+
227
+ const finish = (error) => {
228
+ stop();
229
+ screen.erase();
230
+ stdout.write("\x1B[?25h");
231
+ if (error) throw error;
232
+ };
233
+
234
+ stdout.write("\x1B[?25l"); // hide the cursor while navigating
235
+ paint();
236
+
237
+ return new Promise((resolve) => {
238
+ const toggleAll = () => {
239
+ const shown = visibleChoices();
240
+ const everySelected = shown.every((choice) => selected.has(choice.value));
241
+ if (everySelected) for (const choice of shown) selected.delete(choice.value);
242
+ else for (const choice of shown) selected.add(choice.value);
243
+ };
244
+
245
+ const onKeypress = (str, key) => {
246
+ const name = keyOf(key);
247
+ const all = rows();
248
+
249
+ if (name === KEY.ctrlC) {
250
+ finish();
251
+ process.exit(130);
252
+ }
253
+ if (name === KEY.escape) {
254
+ finish(new CliError("Aborted."));
255
+ return;
256
+ }
257
+ if (name === KEY.enter) {
258
+ if (required && selected.size === 0) {
259
+ status = yellow("! select at least one (enter to confirm)");
260
+ paint();
261
+ return;
262
+ }
263
+ finish();
264
+ resolve(choices.filter((choice) => selected.has(choice.value)));
265
+ return;
266
+ }
267
+ if (name === KEY.up || name === KEY.down) {
268
+ cursor = name === KEY.up ? cursor - 1 : cursor + 1;
269
+ if (cursor < 0) cursor = all.length - 1;
270
+ if (cursor >= all.length) cursor = 0;
271
+ status = "";
272
+ paint();
273
+ return;
274
+ }
275
+ if (name === KEY.space) {
276
+ const row = all[cursor];
277
+ if (row) {
278
+ if (row.value === ALL) toggleAll();
279
+ else if (selected.has(row.value)) selected.delete(row.value);
280
+ else selected.add(row.value);
281
+ }
282
+ status = "";
283
+ paint();
284
+ return;
285
+ }
286
+ if (name === KEY.backspace) {
287
+ filter = filter.slice(0, -1);
288
+ cursor = 0;
289
+ offset = 0;
290
+ status = "";
291
+ paint();
292
+ return;
293
+ }
294
+
295
+ // `a` toggles everything; anything else is filter input.
296
+ if (str === "a" && !key.ctrl && !key.meta) {
297
+ toggleAll();
298
+ status = "";
299
+ paint();
300
+ return;
301
+ }
302
+ if (str && !key.ctrl && !key.meta && visibleWidth(str) > 0) {
303
+ filter += str;
304
+ cursor = 0;
305
+ offset = 0;
306
+ status = "";
307
+ paint();
308
+ }
309
+ };
310
+
311
+ stdin.on("keypress", onKeypress);
312
+ stdin.resume();
313
+ });
314
+ }
315
+
316
+ /** Single-select, same repaint model with enter choosing the cursor row. */
317
+ export function select({ message, choices, initialIndex = 0, hint }) {
44
318
  if (choices.length === 0) return [];
319
+
45
320
  if (!isInteractive()) {
46
- throw new CliError(`${message} needs an interactive terminal`, {
47
- hint: "Pass the choice explicitly instead, e.g. --skill <name> or <source>@<name>.",
321
+ throw new CliError(`"${message}" needs an interactive terminal`, {
322
+ hint: "Pass the choice explicitly as a flag instead.",
48
323
  });
49
324
  }
50
- console.log(`\n${bold(message)}`);
51
- choices.forEach((choice, index) => {
52
- console.log(` ${cyan(String(index + 1).padStart(2))} ${choice.label}`);
325
+
326
+ let cursor = Math.min(Math.max(0, initialIndex), choices.length - 1);
327
+ let offset = 0;
328
+
329
+ const write = (line) => stdout.write(line);
330
+ const screen = createScreen(write);
331
+ const stop = readKeys();
332
+ const pageSize = Math.min(8, Math.max(3, termRows() - 8));
333
+
334
+ const paint = () => {
335
+ if (cursor >= choices.length) cursor = choices.length - 1;
336
+ if (cursor < offset) offset = cursor;
337
+ if (cursor >= offset + pageSize) offset = cursor - pageSize + 1;
338
+
339
+ const window = choices.slice(offset, offset + pageSize);
340
+ const labelWidth = Math.min(28, Math.max(0, ...choices.map((choice) => visibleWidth(choice.label))));
341
+
342
+ const lines = [`${bold("?")} ${bold(message)}`];
343
+ if (hint) lines.push(` ${dim(hint)}`);
344
+
345
+ for (const choice of window) {
346
+ const index = offset + window.indexOf(choice);
347
+ const isCursor = index === cursor;
348
+ const label = isCursor ? bold(pad(choice.label, labelWidth)) : pad(choice.label, labelWidth);
349
+ const description = choice.description ? dim(` ${choice.description}`) : "";
350
+ lines.push(` ${isCursor ? cyan("❯") : " "} ${label}${description}`);
351
+ }
352
+
353
+ if (choices.length > window.length) {
354
+ lines.push(dim(` … ${choices.length - window.length} more`));
355
+ }
356
+ lines.push("");
357
+ lines.push(dim(hint ? "" : "↑/↓ move · enter select"));
358
+ if (lines[lines.length - 1] === "") lines.pop();
359
+
360
+ screen.paint(lines);
361
+ };
362
+
363
+ stdout.write("\x1B[?25l");
364
+ paint();
365
+
366
+ return new Promise((resolve, reject) => {
367
+ const onKeypress = (str, key) => {
368
+ const name = keyOf(key);
369
+
370
+ if (name === KEY.ctrlC) {
371
+ stop();
372
+ screen.erase();
373
+ stdout.write("\x1B[?25h");
374
+ process.exit(130);
375
+ }
376
+ if (name === KEY.escape) {
377
+ stop();
378
+ screen.erase();
379
+ stdout.write("\x1B[?25h");
380
+ reject(new CliError("Aborted."));
381
+ return;
382
+ }
383
+ if (name === KEY.enter) {
384
+ stop();
385
+ screen.erase();
386
+ stdout.write("\x1B[?25h");
387
+ resolve([choices[cursor]]);
388
+ return;
389
+ }
390
+ if (name === KEY.up || name === KEY.down) {
391
+ cursor = name === KEY.up ? cursor - 1 : cursor + 1;
392
+ if (cursor < 0) cursor = choices.length - 1;
393
+ if (cursor >= choices.length) cursor = 0;
394
+ paint();
395
+ }
396
+ };
397
+
398
+ stdin.on("keypress", onKeypress);
399
+ stdin.resume();
53
400
  });
54
- const answer = await question(`\nSelect (1-${choices.length}, comma-separated, blank to skip): `);
55
- if (answer === "") return [];
56
- const indices = answer
57
- .split(",")
58
- .map((part) => Number(part.trim()))
59
- .filter((value) => Number.isInteger(value) && value >= 1 && value <= choices.length);
60
- if (indices.length === 0) {
61
- throw new CliError(`"${answer}" is not one of the listed options`);
401
+ }
402
+
403
+ /**
404
+ * Yes/no. Defaults to `defaultYes` on enter, and to that same value when there
405
+ * is no terminal at all.
406
+ */
407
+ export async function confirm(message, { defaultYes = true } = {}) {
408
+ if (!isInteractive()) return defaultYes;
409
+
410
+ const rl = readline.createInterface({ input: stdin, output: stdout });
411
+ try {
412
+ const suffix = defaultYes ? "[Y/n]" : "[y/N]";
413
+ const answer = (await rl.question(`${bold("?")} ${message} ${dim(suffix)} `)).trim().toLowerCase();
414
+ if (answer === "") return defaultYes;
415
+ return answer === "y" || answer === "yes";
416
+ } finally {
417
+ rl.close();
62
418
  }
63
- return indices.map((index) => choices[index - 1]);
64
419
  }
65
420
 
66
- /** Multi-select with a numbered header, used by `ds-cs add`. */
67
- export async function selectAll(message, choices) {
68
- return select(message, choices);
421
+ export async function promptText(message, fallback = "") {
422
+ if (!isInteractive()) return fallback;
423
+ const rl = readline.createInterface({ input: stdin, output: stdout });
424
+ try {
425
+ const suffix = fallback ? dim(` (${fallback})`) : "";
426
+ const answer = (await rl.question(`${bold("?")} ${message}${suffix} `)).trim();
427
+ return answer || fallback;
428
+ } finally {
429
+ rl.close();
430
+ }
69
431
  }
432
+
433
+ export { select as selectOne, multiselect as selectMany };