@dastyaresocial/ds-cs 0.1.1 → 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 +67 -5
- package/package.json +1 -1
- package/skills/README.md +7 -4
- package/src/agents.mjs +15 -15
- package/src/commands/add.mjs +32 -61
- package/src/commands/init.mjs +19 -20
- package/src/index.mjs +2 -1
- package/src/install.mjs +12 -11
- package/src/preferences.mjs +66 -0
- package/src/setup.mjs +202 -0
- package/src/utils/prompt.mjs +329 -42
package/README.md
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
The CLI for [Dastyare Social — CS](https://cs.dastyare.social). Installs agent skills, wires up MCP, and authenticates to a channel.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
npx ds-cs init
|
|
7
|
-
npx ds-cs add post-with-media
|
|
6
|
+
npx @dastyaresocial/ds-cs init
|
|
7
|
+
npx @dastyaresocial/ds-cs add post-with-media
|
|
8
8
|
```
|
|
9
9
|
|
|
10
10
|
## Why
|
|
@@ -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` |
|
|
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
|
|
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
|
|
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
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 ds-cs init
|
|
7
|
-
npx ds-cs add
|
|
8
|
-
npx 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/`
|
|
@@ -60,7 +63,7 @@ Add `metadata.internal: true` to hide a skill from discovery until `INSTALL_INTE
|
|
|
60
63
|
While inside a DS-CS checkout, `ds-cs add` installs from this directory rather than from GitHub, so edits take effect immediately:
|
|
61
64
|
|
|
62
65
|
```bash
|
|
63
|
-
npx ds-cs add brand-config --force
|
|
66
|
+
npx @dastyaresocial/ds-cs add brand-config --force
|
|
64
67
|
```
|
|
65
68
|
|
|
66
69
|
Run it from a scratch directory to test what a user actually gets:
|
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: "
|
|
23
|
-
{ name: "
|
|
24
|
-
{ name: "
|
|
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
|
/**
|
package/src/commands/add.mjs
CHANGED
|
@@ -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 {
|
|
14
|
-
import {
|
|
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"
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
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
|
|
32
|
-
const
|
|
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:
|
|
41
|
-
|
|
35
|
+
targets: directories,
|
|
36
|
+
global: target.global,
|
|
37
|
+
copy: target.copy,
|
|
38
|
+
interactive: target.interactive,
|
|
42
39
|
});
|
|
43
40
|
|
|
44
|
-
log.success(
|
|
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(
|
|
49
|
+
log.plain(
|
|
50
|
+
` → ${display(link.target)} (${link.linked ? "linked" : "copied — symlinks unavailable"})`
|
|
51
|
+
);
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
log.plain(
|
|
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
|
}
|
package/src/commands/init.mjs
CHANGED
|
@@ -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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
32
|
-
const
|
|
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
|
-
|
|
37
|
-
|
|
34
|
+
const directories = agentDirectories(agents, { projectRoot, global });
|
|
35
|
+
for (const directory of directories) {
|
|
38
36
|
await ensureDir(directory);
|
|
39
37
|
}
|
|
40
38
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
for (const directory of
|
|
44
|
-
|
|
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
|
-
|
|
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/index.mjs
CHANGED
|
@@ -31,7 +31,8 @@ const VERSION = await readVersion();
|
|
|
31
31
|
const USAGE = `${bold("ds-cs")} — skills and tooling for Dastyare Social — CS
|
|
32
32
|
|
|
33
33
|
${bold("Usage")}
|
|
34
|
-
ds-cs <command> [options]
|
|
34
|
+
npx @dastyaresocial/ds-cs <command> [options]
|
|
35
|
+
(or \`ds-cs <command>\` once installed / linked globally)
|
|
35
36
|
|
|
36
37
|
${bold("Commands")}
|
|
37
38
|
init [dir] Prepare a project for DS-CS skills
|
package/src/install.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
hashDirectory,
|
|
13
13
|
removeDirectory,
|
|
14
14
|
} from "./utils/fs.mjs";
|
|
15
|
-
import {
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
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
|
+
}
|
package/src/utils/prompt.mjs
CHANGED
|
@@ -1,69 +1,356 @@
|
|
|
1
|
-
import readline from "node:readline
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
*
|
|
41
|
-
*
|
|
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
|
|
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(
|
|
47
|
-
hint: "Pass the choice explicitly
|
|
249
|
+
throw new CliError(`"${message}" needs an interactive terminal`, {
|
|
250
|
+
hint: "Pass the choice explicitly as a flag instead.",
|
|
48
251
|
});
|
|
49
252
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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 };
|