@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 +78 -3
- package/package.json +1 -1
- package/skills/README.md +6 -3
- package/src/agents.mjs +15 -15
- package/src/commands/add.mjs +32 -61
- package/src/commands/init.mjs +19 -20
- package/src/install.mjs +12 -11
- package/src/preferences.mjs +66 -0
- package/src/setup.mjs +202 -0
- package/src/utils/prompt.mjs +406 -42
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` |
|
|
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
|
|
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
|
|
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
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
|
|
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: "
|
|
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/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 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
|
+
}
|
package/src/utils/prompt.mjs
CHANGED
|
@@ -1,69 +1,433 @@
|
|
|
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.
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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";
|
|
102
|
+
function visibleWidth(text) {
|
|
103
|
+
return stripAnsi(text).length;
|
|
30
104
|
}
|
|
31
105
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
*
|
|
41
|
-
*
|
|
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
|
|
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(
|
|
47
|
-
hint: "Pass the choice explicitly
|
|
321
|
+
throw new CliError(`"${message}" needs an interactive terminal`, {
|
|
322
|
+
hint: "Pass the choice explicitly as a flag instead.",
|
|
48
323
|
});
|
|
49
324
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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 };
|