@dastyaresocial/ds-cs 0.1.2 → 0.2.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.
package/README.md CHANGED
@@ -13,15 +13,66 @@ 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 to toggle · enter to confirm
27
+ ◉ OpenCode .agents/skills
28
+ ◉ Cursor .agents/skills
29
+ ◉ Codex .agents/skills
30
+ ◉ Claude Code .claude/skills
31
+ ○ Windsurf .windsurf/skills
32
+ …
33
+
34
+ ? How should the files be shared between agents?
35
+ ❯ Symlink one copy, updates land everywhere
36
+ Copy independent files per agent
37
+
38
+ ? Which skills would you like to install?
39
+ type to filter · space to toggle · enter to install
40
+ ○ add-component skills/dev/add-component/
41
+ ○ add-orpc-endpoint skills/dev/add-orpc-endpoint/
42
+ …
43
+ ```
44
+
45
+ Scope first, because it decides which directories exist; then which agents read
46
+ the skills; then how those agents share one copy; then what to install.
47
+
48
+ **Your last choices are remembered** in `~/.config/ds-cs/preferences.json`, so
49
+ the next run preselects them and you can just hit enter three times. Delete the
50
+ file to reset. It lives in your user directory, not the repo — scope and agent
51
+ choice are habits, not project configuration.
52
+
53
+ In the skill picker, typing filters against names, paths **and descriptions**, so
54
+ `webh` finds `webhooks` and `manage-stories` (both mention webhooks). Space
55
+ toggles, so you can take several at once.
56
+
57
+ Without a terminal — CI, a pipe, an agent — nothing prompts. `ds-cs add` prints
58
+ a listing, and flags decide:
59
+
60
+ ```bash
61
+ ds-cs add --global # scope
62
+ ds-cs add brand-config -a opencode # agent (repeatable, or a directory path)
63
+ ds-cs add --copy # independent copies
64
+ ds-cs add -y # accept defaults, no prompts
65
+ ```
66
+
16
67
  ## Commands
17
68
 
18
69
  | Command | What it does |
19
70
  |---|---|
20
- | `ds-cs init` | Create skills directories, optionally write MCP config |
71
+ | `ds-cs init` | Prepare skills directories, optionally write MCP config |
21
72
  | `ds-cs add <skill\|source>` | Install skills from DS-CS or anywhere else |
22
73
  | `ds-cs list` | Show what is installed and where it came from |
23
74
  | `ds-cs update [skill…]` | Re-fetch skills that changed upstream |
24
- | `ds-cs remove <skill…>` | Uninstall from every agent |
75
+ | `ds-cs remove <skill…]` | Uninstall skills from every agent |
25
76
  | `ds-cs search [query]` | Search installed skills |
26
77
  | `ds-cs mcp [agent]` | Print, or write, MCP server config |
27
78
  | `ds-cs login` | Store a channel URL and API key |
@@ -124,10 +175,21 @@ Set `metadata.internal: true` to hide a skill from discovery until `INSTALL_INTE
124
175
  ## Development
125
176
 
126
177
  ```bash
127
- npm test # node:test, no dependencies
178
+ npm test # node:test, no dependencies
179
+ npm run sync-skills # refresh the bundled registry copy
128
180
  npm run dev -- --help
129
181
  ```
130
182
 
183
+ The prompts are hand-rolled keypress handling, which cannot be exercised without
184
+ a terminal — every automated check would otherwise stop at `isInteractive()` and
185
+ test the non-interactive path instead. `test/pty_harness.py` runs the real
186
+ binary under a pty, sends keystrokes, and prints the transcript:
187
+
188
+ ```bash
189
+ python3 test/pty_harness.py "ENTER,ENTER,ENTER,s,t,o,SPACE,ENTER" \
190
+ node bin/ds-cs.mjs add
191
+ ```
192
+
131
193
  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
194
 
133
195
  ## 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.0",
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 to toggle · enter to 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 to toggle · enter to 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,356 @@
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. That means implementing the parts that matter
12
+ * for an installer: search/filter over a list, multi-select with pre-selection,
13
+ * and keyboard navigation — plus the guarantee that a non-interactive shell
14
+ * never blocks on a keypress.
15
+ */
16
+
17
+ const KEY = {
18
+ up: "up",
19
+ down: "down",
20
+ space: "space",
21
+ enter: "return",
22
+ escape: "escape",
23
+ ctrlC: "c",
24
+ backspace: "backspace",
25
+ };
26
+
27
+ function readKeys() {
28
+ readline.emitKeypressEvents(stdin);
29
+ if (stdin.isTTY) stdin.setRawMode(true);
30
+ return () => {
31
+ if (stdin.isTTY) stdin.setRawMode(false);
32
+ stdin.pause();
33
+ };
34
+ }
35
+
36
+ function keyOf(key) {
37
+ if (!key) return null;
38
+ switch (key.name) {
39
+ case "up":
40
+ case "k":
41
+ return KEY.up;
42
+ case "down":
43
+ case "j":
44
+ return KEY.down;
45
+ case "space":
46
+ return KEY.space;
47
+ case "return":
48
+ case "enter":
49
+ return KEY.enter;
50
+ case "escape":
51
+ return KEY.escape;
52
+ case "backspace":
53
+ return KEY.backspace;
54
+ default:
55
+ break;
56
+ }
57
+ if (key.ctrl && key.name === KEY.ctrlC) return KEY.ctrlC;
58
+ return null;
59
+ }
60
+
61
+ /**
62
+ * True when we can actually prompt.
63
+ *
64
+ * CI, a pipe, and an agent's shell are all non-interactive. Every prompt below
65
+ * has a documented non-interactive fallback instead of hanging.
10
66
  */
11
67
  export function isInteractive() {
12
68
  return Boolean(stdin.isTTY && stdout.isTTY) && process.env.CI !== "true";
13
69
  }
14
70
 
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
- }
71
+ function visibleWidth(text) {
72
+ return stripAnsi(text).length;
22
73
  }
23
74
 
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";
75
+ function stripAnsi(text) {
76
+ return text.replace(/\u001B\[[0-9;]*m/g, "");
30
77
  }
31
78
 
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;
79
+ function pad(text, width) {
80
+ const gap = width - visibleWidth(text);
81
+ return gap > 0 ? text + " ".repeat(gap) : text;
37
82
  }
38
83
 
39
84
  /**
40
- * Numbered single-select. Returning an empty list means "nothing chosen", which
41
- * callers treat as a clean no-op rather than an error.
85
+ * Search-and-multi-select over a list of choices.
86
+ *
87
+ * This is the prompt an installer lives or dies by: typing filters, space
88
+ * toggles, enter confirms, and the current selection is always visible. Keys
89
+ * that are not commands are treated as filter input, so the user never has to
90
+ * think about which mode they are in.
42
91
  */
43
- export async function select(message, choices) {
92
+ export function multiselect({
93
+ message,
94
+ choices,
95
+ initialSelected = [],
96
+ hint,
97
+ required = false,
98
+ }) {
99
+ if (!isInteractive()) {
100
+ throw new CliError(`"${message}" needs an interactive terminal`, {
101
+ hint: "Pass the choice explicitly instead, e.g. --skill <name> or --agent <name>.",
102
+ });
103
+ }
104
+ if (choices.length === 0) return [];
105
+
106
+ const selected = new Set(initialSelected.filter((value) => choices.some((c) => c.value === value)));
107
+ let cursor = 0;
108
+ let filter = "";
109
+ let offset = 0;
110
+
111
+ const stop = readKeys();
112
+ const write = (line) => stdout.write(line);
113
+ const pageSize = Math.min(10, Math.max(3, stdout.rows ? stdout.rows - 8 : 10));
114
+
115
+ const visible = () =>
116
+ filter === ""
117
+ ? choices
118
+ : choices.filter((choice) =>
119
+ // `keywords` lets a choice carry extra searchable text — a skill's
120
+ // full description, say — without widening what the list shows.
121
+ (choice.keywords ?? `${choice.label} ${choice.description ?? ""}`)
122
+ .toLowerCase()
123
+ .includes(filter.toLowerCase())
124
+ );
125
+
126
+ const render = (bottomLine) => {
127
+ const rows = visible();
128
+ if (cursor >= rows.length) cursor = Math.max(0, rows.length - 1);
129
+ if (cursor < offset) offset = cursor;
130
+ if (cursor >= offset + pageSize) offset = cursor - pageSize + 1;
131
+
132
+ write("\x1B[2K\r");
133
+ write(`${bold("?")} ${bold(message)}\n`);
134
+
135
+ if (hint) write(` ${dim(hint)}\n`);
136
+ if (filter) write(` ${dim("filter:")} ${filter}${cyan("▌")}\n`);
137
+ write("\x1B[1A".repeat(filter ? 2 : hint ? 2 : 1));
138
+
139
+ const window = rows.slice(offset, offset + pageSize);
140
+ const labelWidth = Math.max(
141
+ 0,
142
+ ...choices.map((choice) => visibleWidth(choice.label))
143
+ );
144
+
145
+ for (const choice of window) {
146
+ const index = rows.indexOf(choice);
147
+ const isCursor = index === cursor;
148
+ const isSelected = selected.has(choice.value);
149
+ const marker = isSelected ? cyan("◉") : dim("○");
150
+ const pointer = isCursor ? cyan("❯") : " ";
151
+ const label = isCursor ? bold(pad(choice.label, labelWidth)) : pad(choice.label, labelWidth);
152
+ const description = choice.description ? dim(` ${choice.description}`) : "";
153
+ write(` ${pointer} ${marker} ${label}${description}\n`);
154
+ }
155
+
156
+ write("\x1B[1A".repeat(window.length));
157
+ write("\x1B[2K\r");
158
+ write(`${bottomLine}\n`);
159
+ };
160
+
161
+ const cleanup = (message) => {
162
+ stop();
163
+ write("\x1B[?25h"); // show cursor
164
+ if (message) write(`${message}\n`);
165
+ };
166
+
167
+ stdout.write("\x1B[?25l"); // hide cursor for the duration of the prompt
168
+
169
+ return new Promise((resolve, reject) => {
170
+ const summary = () =>
171
+ selected.size === 0
172
+ ? `${dim("(none selected)")}`
173
+ : dim(`(${selected.size} selected: ${[...selected].join(", ")})`);
174
+
175
+ render(summary());
176
+
177
+ const onKeypress = (str, key) => {
178
+ const name = keyOf(key);
179
+
180
+ if (name === KEY.ctrlC) {
181
+ write("\x1B[1A".repeat(1)); // clear summary row
182
+ cleanup();
183
+ process.exit(130);
184
+ }
185
+
186
+ if (name === KEY.escape) {
187
+ cleanup();
188
+ reject(new CliError("Aborted."));
189
+ return;
190
+ }
191
+
192
+ if (name === KEY.enter) {
193
+ if (required && selected.size === 0) {
194
+ render(`${yellow("!")} select at least one (enter to confirm)`);
195
+ return;
196
+ }
197
+ cleanup();
198
+ resolve(choices.filter((choice) => selected.has(choice.value)));
199
+ return;
200
+ }
201
+
202
+ if (name === KEY.up || name === KEY.down) {
203
+ const rows = visible();
204
+ if (rows.length === 0) return;
205
+ cursor = name === KEY.up ? cursor - 1 : cursor + 1;
206
+ if (cursor < 0) cursor = rows.length - 1;
207
+ if (cursor >= rows.length) cursor = 0;
208
+ render(summary());
209
+ return;
210
+ }
211
+
212
+ if (name === KEY.space) {
213
+ const rows = visible();
214
+ const choice = rows[cursor];
215
+ if (!choice) return;
216
+ if (selected.has(choice.value)) selected.delete(choice.value);
217
+ else selected.add(choice.value);
218
+ render(summary());
219
+ return;
220
+ }
221
+
222
+ if (name === KEY.backspace) {
223
+ filter = filter.slice(0, -1);
224
+ cursor = 0;
225
+ offset = 0;
226
+ render(summary());
227
+ return;
228
+ }
229
+
230
+ // Anything else is filter input.
231
+ if (str && !key.ctrl && !key.meta && visibleWidth(str) > 0) {
232
+ filter += str;
233
+ cursor = 0;
234
+ offset = 0;
235
+ render(summary());
236
+ }
237
+ };
238
+
239
+ stdin.on("keypress", onKeypress);
240
+ stdin.resume();
241
+ });
242
+ }
243
+
244
+ /** Single-select, same interaction model with `enter` selecting the cursor. */
245
+ export function select({ message, choices, initialIndex = 0 }) {
44
246
  if (choices.length === 0) return [];
247
+
45
248
  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>.",
249
+ throw new CliError(`"${message}" needs an interactive terminal`, {
250
+ hint: "Pass the choice explicitly as a flag instead.",
48
251
  });
49
252
  }
50
- console.log(`\n${bold(message)}`);
51
- choices.forEach((choice, index) => {
52
- console.log(` ${cyan(String(index + 1).padStart(2))} ${choice.label}`);
253
+
254
+ let cursor = Math.min(initialIndex, choices.length - 1);
255
+ let offset = 0;
256
+ const stop = readKeys();
257
+ const write = (line) => stdout.write(line);
258
+ const pageSize = Math.min(10, Math.max(3, stdout.rows ? stdout.rows - 8 : 10));
259
+
260
+ const render = () => {
261
+ if (cursor >= choices.length) cursor = choices.length - 1;
262
+ if (cursor < offset) offset = cursor;
263
+ if (cursor >= offset + pageSize) offset = cursor - pageSize + 1;
264
+
265
+ write("\x1B[2K\r");
266
+ write(`${bold("?")} ${bold(message)}\n`);
267
+
268
+ const window = choices.slice(offset, offset + pageSize);
269
+ const labelWidth = Math.max(0, ...choices.map((choice) => visibleWidth(choice.label)));
270
+
271
+ for (const choice of window) {
272
+ const index = offset + window.indexOf(choice);
273
+ const pointer = index === cursor ? cyan("❯") : " ";
274
+ const label = index === cursor ? bold(pad(choice.label, labelWidth)) : pad(choice.label, labelWidth);
275
+ const description = choice.description ? dim(` ${choice.description}`) : "";
276
+ write(` ${pointer} ${label}${description}\n`);
277
+ }
278
+
279
+ write("\x1B[1A".repeat(window.length));
280
+ write("\x1B[2K\r");
281
+ write(dim(" ↑/↓ move · enter select\n"));
282
+ };
283
+
284
+ const cleanup = () => {
285
+ stop();
286
+ write("\x1B[?25h");
287
+ };
288
+
289
+ stdout.write("\x1B[?25l"); // hide cursor for the duration of the prompt
290
+
291
+ return new Promise((resolve, reject) => {
292
+ render();
293
+
294
+ const onKeypress = (str, key) => {
295
+ const name = keyOf(key);
296
+
297
+ if (name === KEY.ctrlC) {
298
+ cleanup();
299
+ process.exit(130);
300
+ }
301
+ if (name === KEY.escape) {
302
+ cleanup();
303
+ reject(new CliError("Aborted."));
304
+ return;
305
+ }
306
+ if (name === KEY.enter) {
307
+ cleanup();
308
+ resolve([choices[cursor]]);
309
+ return;
310
+ }
311
+ if (name === KEY.up || name === KEY.down) {
312
+ cursor = name === KEY.up ? cursor - 1 : cursor + 1;
313
+ if (cursor < 0) cursor = choices.length - 1;
314
+ if (cursor >= choices.length) cursor = 0;
315
+ render();
316
+ }
317
+ };
318
+
319
+ stdin.on("keypress", onKeypress);
320
+ stdin.resume();
53
321
  });
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`);
322
+ }
323
+
324
+ /**
325
+ * Yes/no. Defaults to `defaultYes` on enter, and to that same value when there
326
+ * is no terminal at all.
327
+ */
328
+ export async function confirm(message, { defaultYes = true } = {}) {
329
+ if (!isInteractive()) return defaultYes;
330
+
331
+ const { createInterface } = readline;
332
+ const rl = createInterface({ input: stdin, output: stdout });
333
+ try {
334
+ const suffix = defaultYes ? "[Y/n]" : "[y/N]";
335
+ const answer = (await rl.question(`${bold("?")} ${message} ${dim(suffix)} `)).trim().toLowerCase();
336
+ if (answer === "") return defaultYes;
337
+ return answer === "y" || answer === "yes";
338
+ } finally {
339
+ rl.close();
62
340
  }
63
- return indices.map((index) => choices[index - 1]);
64
341
  }
65
342
 
66
- /** Multi-select with a numbered header, used by `ds-cs add`. */
67
- export async function selectAll(message, choices) {
68
- return select(message, choices);
343
+ export async function promptText(message, fallback = "") {
344
+ if (!isInteractive()) return fallback;
345
+ const { createInterface } = readline;
346
+ const rl = createInterface({ input: stdin, output: stdout });
347
+ try {
348
+ const suffix = fallback ? dim(` (${fallback})`) : "";
349
+ const answer = (await rl.question(`${bold("?")} ${message}${suffix} `)).trim();
350
+ return answer || fallback;
351
+ } finally {
352
+ rl.close();
353
+ }
69
354
  }
355
+
356
+ export { select as selectOne, multiselect as selectMany };