@solidactions/cli 1.12.0 → 1.13.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.
@@ -0,0 +1,168 @@
1
+ "use strict";
2
+ /**
3
+ * solidactions role push <dir>
4
+ *
5
+ * Pushes a role definition to the crews library (idempotent upsert).
6
+ * Roles are flat peers of skills (per cli#34) — NOT nested under a role.
7
+ *
8
+ * The role def is a SKILL.md-shaped file (frontmatter name+description, body).
9
+ * Roles carry NO references.
10
+ *
11
+ * Supports --dry-run: pre-flight a read to detect existence; prints
12
+ * "[dry-run] would create/update '<name>'" without making any create/edit call.
13
+ *
14
+ * Upsert: create → on name_collision → edit.
15
+ * Roles use 'name' (NOT 'identifier') for read and edit.
16
+ */
17
+ var __importDefault = (this && this.__importDefault) || function (mod) {
18
+ return (mod && mod.__esModule) ? mod : { "default": mod };
19
+ };
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.rolePushWithConfig = rolePushWithConfig;
22
+ exports.rolePush = rolePush;
23
+ const fs_1 = __importDefault(require("fs"));
24
+ const path_1 = __importDefault(require("path"));
25
+ const chalk_1 = __importDefault(require("chalk"));
26
+ const api_1 = require("../utils/api");
27
+ const mcp_1 = require("../utils/mcp");
28
+ const skill_push_1 = require("./skill-push");
29
+ /**
30
+ * Core implementation — accepts an injected config so tests can point at a
31
+ * stub server without touching the filesystem config.
32
+ *
33
+ * Reads <dir>/SKILL.md (errors if missing), parses it via parseSkillFile,
34
+ * then upserts the role via the crews 'roles' MCP tool.
35
+ *
36
+ * Roles carry NO references. The 'roles' tool uses 'name' for read and edit
37
+ * (NOT 'identifier'). Dry-run pre-flight uses {action:'read', name}.
38
+ */
39
+ async function rolePushWithConfig(dir, options, config) {
40
+ const absDir = path_1.default.resolve(dir);
41
+ // Verify the directory exists
42
+ if (!fs_1.default.existsSync(absDir) || !fs_1.default.statSync(absDir).isDirectory()) {
43
+ process.stderr.write(chalk_1.default.red(`error: "${dir}" is not a directory.\n`));
44
+ process.exit(1);
45
+ }
46
+ // Read and parse SKILL.md (roles use the same SKILL.md filename convention)
47
+ const skillMdPath = path_1.default.join(absDir, 'SKILL.md');
48
+ if (!fs_1.default.existsSync(skillMdPath)) {
49
+ process.stderr.write(chalk_1.default.red(`error: "${skillMdPath}" not found. The role directory must contain a SKILL.md file.\n`));
50
+ process.exit(1);
51
+ }
52
+ const skillMdContent = fs_1.default.readFileSync(skillMdPath, 'utf8');
53
+ let name;
54
+ let description;
55
+ let properties;
56
+ let body;
57
+ try {
58
+ ({ name, description, properties, body } = (0, skill_push_1.parseSkillFile)(skillMdContent));
59
+ }
60
+ catch (e) {
61
+ process.stderr.write(chalk_1.default.red(`error: ${e.message}\n`));
62
+ process.exit(1);
63
+ }
64
+ // --dry-run: pre-flight a read to detect existence; NO create or edit.
65
+ // Roles use {action:'read', name} (NOT identifier).
66
+ if (options.dryRun) {
67
+ let readResult;
68
+ try {
69
+ readResult = await (0, mcp_1.callCrewsTool)(config, 'roles', { action: 'read', name });
70
+ }
71
+ catch (e) {
72
+ process.stderr.write(chalk_1.default.red(`error: MCP request failed — ${e.message}\n`));
73
+ process.exit(1);
74
+ }
75
+ if (!readResult.ok) {
76
+ const code = readResult.data?.code;
77
+ if (code === 'role_not_found') {
78
+ if (options.json) {
79
+ console.log(JSON.stringify({}));
80
+ }
81
+ else {
82
+ console.log(chalk_1.default.cyan(`[dry-run] would create '${name}'`));
83
+ }
84
+ process.exit(0);
85
+ }
86
+ // Any other error is unexpected — surface it
87
+ const errMsg = readResult.data?.message ?? 'MCP returned an error with no message';
88
+ process.stderr.write(chalk_1.default.red(`error: ${code ?? 'unknown_error'}: ${errMsg}\n`));
89
+ process.exit(1);
90
+ }
91
+ // Read succeeded → role exists → would update
92
+ if (options.json) {
93
+ console.log(JSON.stringify({}));
94
+ }
95
+ else {
96
+ console.log(chalk_1.default.cyan(`[dry-run] would update '${name}'`));
97
+ }
98
+ process.exit(0);
99
+ }
100
+ // Upsert: try create first; on name_collision switch to edit.
101
+ // Roles carry NO references — do not send references key.
102
+ let result;
103
+ try {
104
+ result = await (0, mcp_1.callCrewsTool)(config, 'roles', {
105
+ action: 'create',
106
+ name,
107
+ description,
108
+ body,
109
+ properties,
110
+ });
111
+ }
112
+ catch (e) {
113
+ process.stderr.write(chalk_1.default.red(`error: MCP request failed — ${e.message}\n`));
114
+ process.exit(1);
115
+ }
116
+ // On name collision, switch to the edit path.
117
+ // Roles edit uses 'name' (NOT 'identifier'), and properties → properties_patch.
118
+ if (!result.ok && result.data?.code === 'name_collision') {
119
+ try {
120
+ result = await (0, mcp_1.callCrewsTool)(config, 'roles', {
121
+ action: 'edit',
122
+ name,
123
+ description,
124
+ body,
125
+ properties_patch: properties,
126
+ });
127
+ }
128
+ catch (e) {
129
+ process.stderr.write(chalk_1.default.red(`error: MCP request failed — ${e.message}\n`));
130
+ process.exit(1);
131
+ }
132
+ if (!result.ok) {
133
+ const errCode = result.data?.code ?? 'unknown_error';
134
+ const errMsg = result.data?.message ?? 'MCP returned an error with no message';
135
+ process.stderr.write(chalk_1.default.red(`error: ${errCode}: ${errMsg}\n`));
136
+ process.exit(1);
137
+ }
138
+ if (options.json) {
139
+ console.log(JSON.stringify(result.data ?? {}));
140
+ }
141
+ else {
142
+ console.log(chalk_1.default.green(`updated role '${name}'`));
143
+ }
144
+ process.exit(0);
145
+ }
146
+ if (!result.ok) {
147
+ const errCode = result.data?.code ?? 'unknown_error';
148
+ const errMsg = result.data?.message ?? 'MCP returned an error with no message';
149
+ process.stderr.write(chalk_1.default.red(`error: ${errCode}: ${errMsg}\n`));
150
+ process.exit(1);
151
+ }
152
+ if (options.json) {
153
+ console.log(JSON.stringify(result.data ?? {}));
154
+ }
155
+ else {
156
+ const data = result.data ?? {};
157
+ const roleDocId = data.role_doc_id ?? data.doc_id ?? data.id ?? '?';
158
+ console.log(chalk_1.default.green(`created role '${name}' (doc ${roleDocId})`));
159
+ }
160
+ process.exit(0);
161
+ }
162
+ /**
163
+ * Entry point called from index.ts.
164
+ */
165
+ async function rolePush(dir, options) {
166
+ const config = await (0, api_1.requireConfigWithWorkspace)();
167
+ await rolePushWithConfig(dir, options, config);
168
+ }
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ /**
3
+ * solidactions skill delete <name>
4
+ *
5
+ * Deletes a skill from the crews library. Admin only.
6
+ */
7
+ var __importDefault = (this && this.__importDefault) || function (mod) {
8
+ return (mod && mod.__esModule) ? mod : { "default": mod };
9
+ };
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.skillDeleteWithConfig = skillDeleteWithConfig;
12
+ exports.skillDelete = skillDelete;
13
+ const chalk_1 = __importDefault(require("chalk"));
14
+ const api_1 = require("../utils/api");
15
+ const mcp_1 = require("../utils/mcp");
16
+ /**
17
+ * Core implementation — accepts an injected config so tests can point at a
18
+ * stub server without touching the filesystem config.
19
+ */
20
+ async function skillDeleteWithConfig(name, options, config) {
21
+ let result;
22
+ try {
23
+ result = await (0, mcp_1.callCrewsTool)(config, 'skills', { action: 'delete', identifier: name });
24
+ }
25
+ catch (e) {
26
+ process.stderr.write(chalk_1.default.red(`error: ${e.message}\n`));
27
+ process.exit(1);
28
+ }
29
+ if (!result.ok) {
30
+ const code = result.data?.code ?? 'unknown_error';
31
+ const message = result.data?.message ?? 'MCP returned an error with no message';
32
+ if (code === 'permission_denied') {
33
+ process.stderr.write(chalk_1.default.red(`error: permission denied — deleting a skill requires Admin access.\n`));
34
+ }
35
+ else {
36
+ process.stderr.write(chalk_1.default.red(`error: ${code}: ${message}\n`));
37
+ }
38
+ process.exit(1);
39
+ }
40
+ if (options.json) {
41
+ console.log(JSON.stringify(result.data));
42
+ process.exit(0);
43
+ }
44
+ const deleteToken = result.data?.delete_token ?? '';
45
+ console.log(chalk_1.default.green(`deleted skill '${name}'`) + (deleteToken ? chalk_1.default.gray(` (token: ${deleteToken})`) : ''));
46
+ process.exit(0);
47
+ }
48
+ /**
49
+ * Entry point called from index.ts.
50
+ */
51
+ async function skillDelete(name, options) {
52
+ const config = await (0, api_1.requireConfigWithWorkspace)();
53
+ await skillDeleteWithConfig(name, options, config);
54
+ }
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ /**
3
+ * solidactions skill list
4
+ *
5
+ * Lists skills in the crews library.
6
+ */
7
+ var __importDefault = (this && this.__importDefault) || function (mod) {
8
+ return (mod && mod.__esModule) ? mod : { "default": mod };
9
+ };
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.skillListWithConfig = skillListWithConfig;
12
+ exports.skillList = skillList;
13
+ const chalk_1 = __importDefault(require("chalk"));
14
+ const api_1 = require("../utils/api");
15
+ const mcp_1 = require("../utils/mcp");
16
+ /**
17
+ * Core implementation — accepts an injected config so tests can point at a
18
+ * stub server without touching the filesystem config.
19
+ */
20
+ async function skillListWithConfig(options, config) {
21
+ const args = { action: 'list' };
22
+ if (options.limit !== undefined) {
23
+ args.limit = options.limit;
24
+ }
25
+ let result;
26
+ try {
27
+ result = await (0, mcp_1.callCrewsTool)(config, 'skills', args);
28
+ }
29
+ catch (e) {
30
+ process.stderr.write(chalk_1.default.red(`error: ${e.message}\n`));
31
+ process.exit(1);
32
+ }
33
+ if (!result.ok) {
34
+ const code = result.data?.code ?? 'unknown_error';
35
+ const message = result.data?.message ?? 'MCP returned an error with no message';
36
+ process.stderr.write(chalk_1.default.red(`error: ${code}: ${message}\n`));
37
+ process.exit(1);
38
+ }
39
+ const skills = result.data?.skills ?? [];
40
+ if (options.json) {
41
+ console.log(JSON.stringify(result.data));
42
+ process.exit(0);
43
+ }
44
+ if (skills.length === 0) {
45
+ console.log(chalk_1.default.gray('No skills found.'));
46
+ process.exit(0);
47
+ }
48
+ for (const skill of skills) {
49
+ const advertisedMarker = skill.catalog_advertised ? chalk_1.default.gray(' (advertised)') : '';
50
+ console.log(`${chalk_1.default.green(skill.name)} — ${chalk_1.default.gray(skill.description)}${advertisedMarker}`);
51
+ }
52
+ process.exit(0);
53
+ }
54
+ /**
55
+ * Entry point called from index.ts.
56
+ */
57
+ async function skillList(options) {
58
+ const config = await (0, api_1.requireConfigWithWorkspace)();
59
+ await skillListWithConfig(options, config);
60
+ }
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ /**
3
+ * solidactions skill pull <name> [dest]
4
+ *
5
+ * Fetches a skill from the crews library and reconstructs a local skill folder
6
+ * (the inverse of `skill push`): writes <dest>/SKILL.md + any bundled reference
7
+ * files under <dest>/. Default dest = ./<name>/.
8
+ *
9
+ * --json mode: prints the raw read result from the server and exits WITHOUT
10
+ * writing any files. This is useful for scripting/inspection. The default
11
+ * (no --json) writes the folder.
12
+ */
13
+ var __importDefault = (this && this.__importDefault) || function (mod) {
14
+ return (mod && mod.__esModule) ? mod : { "default": mod };
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.skillPullWithConfig = skillPullWithConfig;
18
+ exports.skillPull = skillPull;
19
+ const fs_1 = __importDefault(require("fs"));
20
+ const path_1 = __importDefault(require("path"));
21
+ const chalk_1 = __importDefault(require("chalk"));
22
+ const js_yaml_1 = __importDefault(require("js-yaml"));
23
+ const api_1 = require("../utils/api");
24
+ const mcp_1 = require("../utils/mcp");
25
+ /**
26
+ * Core implementation — accepts an injected config so tests can point at a
27
+ * stub server without touching the filesystem config.
28
+ *
29
+ * Steps:
30
+ * 1. Call skills.read to fetch the skill from the library.
31
+ * 2. If --json: print the raw result, exit 0 (no file writes).
32
+ * 3. Otherwise: reconstruct SKILL.md from the returned `properties` (which
33
+ * contains name, description, and any extra props), OMITTING `type` (the
34
+ * server sets it; stripping mirrors what parseSkillFile does on push so a
35
+ * push→pull→push round-trip is idempotent). Write <dest>/SKILL.md, then
36
+ * write each reference file under <dest>/ with a path-traversal guard.
37
+ */
38
+ async function skillPullWithConfig(name, dest, options, config) {
39
+ // Call the crews MCP server for a skills.read
40
+ let result;
41
+ try {
42
+ result = await (0, mcp_1.callCrewsTool)(config, 'skills', { action: 'read', identifier: name });
43
+ }
44
+ catch (e) {
45
+ process.stderr.write(chalk_1.default.red(`error: MCP request failed — ${e.message}\n`));
46
+ process.exit(1);
47
+ }
48
+ if (!result.ok) {
49
+ const code = result.data?.code ?? 'unknown_error';
50
+ const message = result.data?.message ?? 'MCP returned an error with no message';
51
+ process.stderr.write(chalk_1.default.red(`error: ${code}: ${message}\n`));
52
+ process.exit(1);
53
+ }
54
+ const data = result.data;
55
+ // --json mode: print raw result and exit WITHOUT writing files.
56
+ // This is intentionally non-destructive: inspect the raw server payload
57
+ // without side-effects. File reconstruction is the default (no --json) mode.
58
+ if (options.json) {
59
+ console.log(JSON.stringify(data));
60
+ process.exit(0);
61
+ }
62
+ // Resolve the output directory (default: ./<name>/)
63
+ const out = dest ?? './' + name;
64
+ const absOut = path_1.default.resolve(out);
65
+ // Build the frontmatter object from `properties`, OMITTING `type`.
66
+ // The server stores a `type` field but it is stripped on push (parseSkillFile
67
+ // does `const { type: _type, ...properties } = rest`). We mirror that here
68
+ // so that pull → push is idempotent.
69
+ const { type: _type, name: propName, description: propDescription, ...extraProps } = data.properties;
70
+ // Reconstruct the full frontmatter: name + description (from properties)
71
+ // followed by all remaining extra props (minus type).
72
+ const frontmatterObj = {
73
+ name: propName,
74
+ description: propDescription,
75
+ ...extraProps,
76
+ };
77
+ // yaml.dump produces "key: value\n" lines; we wrap in ---\n...\n---\n
78
+ const yamlBlock = js_yaml_1.default.dump(frontmatterObj, { lineWidth: -1 });
79
+ const body = data.body ?? '';
80
+ // Ensure the body starts on its own line after the closing ---
81
+ const skillMdContent = `---\n${yamlBlock}---\n${body.startsWith('\n') ? body : '\n' + body}`;
82
+ // Create the output directory (and parents) if needed
83
+ fs_1.default.mkdirSync(absOut, { recursive: true });
84
+ // Write SKILL.md
85
+ fs_1.default.writeFileSync(path_1.default.join(absOut, 'SKILL.md'), skillMdContent, 'utf8');
86
+ // Write reference files, guarding against path traversal
87
+ const reference = data.reference ?? {};
88
+ const absOutNorm = path_1.default.resolve(absOut) + path_1.default.sep;
89
+ let writtenCount = 0;
90
+ let skippedCount = 0;
91
+ for (const [key, content] of Object.entries(reference)) {
92
+ // Guard 1: reject absolute paths
93
+ if (path_1.default.isAbsolute(key)) {
94
+ process.stderr.write(chalk_1.default.yellow(`warn: skipping unsafe reference key (absolute path): ${key}\n`));
95
+ skippedCount++;
96
+ continue;
97
+ }
98
+ // Guard 2: reject path traversal (any key that resolves outside absOut)
99
+ const target = path_1.default.resolve(absOut, key);
100
+ if (!target.startsWith(absOutNorm)) {
101
+ process.stderr.write(chalk_1.default.yellow(`warn: skipping unsafe reference key (traversal): ${key}\n`));
102
+ skippedCount++;
103
+ continue;
104
+ }
105
+ // Safe: create parent dirs and write the file
106
+ fs_1.default.mkdirSync(path_1.default.dirname(target), { recursive: true });
107
+ fs_1.default.writeFileSync(target, content, 'utf8');
108
+ writtenCount++;
109
+ }
110
+ const refSummary = writtenCount === 1 ? '1 reference' : `${writtenCount} references`;
111
+ console.log(chalk_1.default.green(`pulled skill '${name}' → ${out} (${refSummary})`));
112
+ process.exit(0);
113
+ }
114
+ /**
115
+ * Entry point called from index.ts.
116
+ */
117
+ async function skillPull(name, dest, options) {
118
+ const config = await (0, api_1.requireConfigWithWorkspace)();
119
+ await skillPullWithConfig(name, dest, options, config);
120
+ }
@@ -4,13 +4,20 @@
4
4
  *
5
5
  * Pushes a local skill folder into the crews library via the crews MCP server.
6
6
  * Idempotent upsert: create, or update on name collision.
7
+ *
8
+ * Supports two modes:
9
+ * - Single-skill: <dir>/SKILL.md exists → push that one skill.
10
+ * - Plugin: <dir>/skills/<name>/SKILL.md and/or <dir>/commands/<name>.md → push all.
7
11
  */
8
12
  var __importDefault = (this && this.__importDefault) || function (mod) {
9
13
  return (mod && mod.__esModule) ? mod : { "default": mod };
10
14
  };
11
15
  Object.defineProperty(exports, "__esModule", { value: true });
12
16
  exports.parseSkillFile = parseSkillFile;
13
- exports.readTopLevelReferences = readTopLevelReferences;
17
+ exports.readReferences = readReferences;
18
+ exports.readSkillDir = readSkillDir;
19
+ exports.parseCommandFile = parseCommandFile;
20
+ exports.pushParsedSkill = pushParsedSkill;
14
21
  exports.skillPushWithConfig = skillPushWithConfig;
15
22
  exports.skillPush = skillPush;
16
23
  const fs_1 = __importDefault(require("fs"));
@@ -65,55 +72,141 @@ function parseSkillFile(content) {
65
72
  return { name, description, properties, body };
66
73
  }
67
74
  /**
68
- * Read top-level (non-recursive) files in dir, excluding SKILL.md.
69
- * Returns a map of filename → utf8 contents.
75
+ * Recursively read every bundled file under a skill dir, excluding the
76
+ * top-level SKILL.md. Returns a map of reference-key → utf8 contents.
77
+ *
78
+ * Keys are the file's POSIX path relative to the skill dir: top-level files
79
+ * keep a bare-filename key (e.g. "helper.py"), and files in subfolders keep
80
+ * their relative path (e.g. "references/member-roles.md") — matching how
81
+ * SKILL.md cites them, so bundled reference docs land complete (#247).
70
82
  */
71
- function readTopLevelReferences(dir) {
72
- const entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
83
+ function readReferences(dir) {
73
84
  const references = {};
74
- for (const entry of entries) {
75
- if (!entry.isFile())
76
- continue;
77
- if (entry.name === 'SKILL.md')
78
- continue;
79
- const filePath = path_1.default.join(dir, entry.name);
80
- references[entry.name] = fs_1.default.readFileSync(filePath, 'utf8');
81
- }
85
+ const walk = (current) => {
86
+ for (const entry of fs_1.default.readdirSync(current, { withFileTypes: true })) {
87
+ const abs = path_1.default.join(current, entry.name);
88
+ if (entry.isDirectory()) {
89
+ walk(abs);
90
+ continue;
91
+ }
92
+ if (!entry.isFile())
93
+ continue;
94
+ // POSIX-normalised path relative to the skill dir, used as the key.
95
+ const key = path_1.default.relative(dir, abs).split(path_1.default.sep).join('/');
96
+ if (key === 'SKILL.md')
97
+ continue; // exclude only the top-level skill file
98
+ references[key] = fs_1.default.readFileSync(abs, 'utf8');
99
+ }
100
+ };
101
+ walk(dir);
82
102
  return references;
83
103
  }
84
104
  /**
85
- * Core implementation accepts an injected config so tests can point at a
86
- * stub server without touching the filesystem config.
105
+ * Read a skill directory: validates the dir exists, reads SKILL.md, parses it,
106
+ * and reads bundled reference files. Returns the full payload for pushParsedSkill.
107
+ *
108
+ * Throws with a descriptive message on any filesystem or parse error.
87
109
  */
88
- async function skillPushWithConfig(dir, options, config) {
110
+ function readSkillDir(dir) {
89
111
  const absDir = path_1.default.resolve(dir);
90
- // 1. Check directory exists
91
112
  if (!fs_1.default.existsSync(absDir) || !fs_1.default.statSync(absDir).isDirectory()) {
92
- process.stderr.write(chalk_1.default.red(`error: "${dir}" is not a directory.\n`));
93
- process.exit(1);
113
+ throw new Error(`"${dir}" is not a directory.`);
94
114
  }
95
- // 2. Read SKILL.md
96
115
  const skillMdPath = path_1.default.join(absDir, 'SKILL.md');
97
116
  if (!fs_1.default.existsSync(skillMdPath)) {
98
- process.stderr.write(chalk_1.default.red(`error: "${skillMdPath}" not found. The skill directory must contain a SKILL.md file.\n`));
99
- process.exit(1);
117
+ throw new Error(`"${skillMdPath}" not found. The skill directory must contain a SKILL.md file.`);
100
118
  }
101
119
  const skillMdContent = fs_1.default.readFileSync(skillMdPath, 'utf8');
102
- // 3. Parse frontmatter + body
103
- let parsed;
120
+ const { name, description, properties, body } = parseSkillFile(skillMdContent);
121
+ const references = readReferences(absDir);
122
+ return { name, description, properties, body, references };
123
+ }
124
+ /**
125
+ * Parse a commands/<name>.md file (plugin command format).
126
+ *
127
+ * Derives the skill name from the filename (kebab-case: lowercase, spaces and
128
+ * underscores replaced with hyphens). The frontmatter must contain `description`;
129
+ * `name`, `allowed-tools`, and `argument-hint` are not carried forward.
130
+ *
131
+ * Returns a SkillPayload with properties={} and references={}.
132
+ * Throws with a descriptive message if description is missing or frontmatter malformed.
133
+ */
134
+ function parseCommandFile(filename, content) {
135
+ // Derive name from filename (basename without extension, converted to kebab-case)
136
+ const base = path_1.default.basename(filename, '.md');
137
+ const name = base.toLowerCase().replace(/[\s_]+/g, '-');
138
+ if (!content.startsWith('---')) {
139
+ throw new Error(`${filename}: command file must begin with a YAML frontmatter block (---).`);
140
+ }
141
+ const afterOpen = content.slice(3);
142
+ const closeIdx = afterOpen.indexOf('\n---');
143
+ if (closeIdx === -1) {
144
+ throw new Error(`${filename}: command file frontmatter is not closed (missing closing ---).`);
145
+ }
146
+ const yamlBlock = afterOpen.slice(0, closeIdx);
147
+ const body = afterOpen.slice(closeIdx + 4).replace(/^\n/, '');
148
+ let fm;
104
149
  try {
105
- parsed = parseSkillFile(skillMdContent);
150
+ fm = js_yaml_1.default.load(yamlBlock);
106
151
  }
107
152
  catch (e) {
108
- process.stderr.write(chalk_1.default.red(`error: ${e.message}\n`));
109
- process.exit(1);
153
+ throw new Error(`${filename}: failed to parse command file frontmatter YAML: ${e.message}`);
154
+ }
155
+ if (!fm || typeof fm !== 'object') {
156
+ throw new Error(`${filename}: command file frontmatter is empty or not a YAML object.`);
157
+ }
158
+ if (!fm.description || typeof fm.description !== 'string') {
159
+ throw new Error(`${filename}: command file frontmatter must contain a "description" field (string).`);
110
160
  }
111
- const { name, description, properties, body } = parsed;
112
- // 4. Read sibling reference files
113
- const references = readTopLevelReferences(absDir);
114
- // 5. Compose an idempotent upsert: try create; on name_collision, edit.
161
+ return {
162
+ name,
163
+ description: fm.description,
164
+ properties: {},
165
+ body,
166
+ references: {},
167
+ };
168
+ }
169
+ /**
170
+ * Core upsert: try create; on name_collision, switch to edit.
171
+ * Returns a result object — does NOT call process.exit and does NOT print.
172
+ * Throws on non-collision MCP errors.
173
+ *
174
+ * When options.dryRun is true, performs only a skills.read pre-flight and
175
+ * returns {status:'would-create'} or {status:'would-update'} without any
176
+ * create or edit call.
177
+ */
178
+ async function pushParsedSkill(payload, options, config) {
179
+ const { name, description, properties, body, references } = payload;
115
180
  const isRole = !!options.role;
116
181
  const tool = isRole ? 'roles' : 'skills';
182
+ // --dry-run: pre-flight a read to detect existence; make NO create/edit calls.
183
+ if (options.dryRun) {
184
+ // Role-scoped skills are read via the roles tool's read_skill {role, name};
185
+ // shared skills via the skills tool's read {identifier}. Both return
186
+ // `skill_not_found` when absent. (read does NOT accept `identifier` on the
187
+ // roles tool, so the action/args must branch on isRole.)
188
+ const readArgs = isRole
189
+ ? { action: 'read_skill', role: options.role, name }
190
+ : { action: 'read', identifier: name };
191
+ let readResult;
192
+ try {
193
+ readResult = await (0, mcp_1.callCrewsTool)(config, tool, readArgs);
194
+ }
195
+ catch (e) {
196
+ throw new Error(`MCP request failed — ${e.message}`);
197
+ }
198
+ if (!readResult.ok) {
199
+ const code = readResult.data?.code;
200
+ if (code === 'skill_not_found') {
201
+ return { status: 'would-create', name, data: {} };
202
+ }
203
+ // Any other error is unexpected — surface it
204
+ const errMsg = readResult.data?.message ?? 'MCP returned an error with no message';
205
+ throw new Error(`${code ?? 'unknown_error'}: ${errMsg}`);
206
+ }
207
+ // Read succeeded → skill exists → would update
208
+ return { status: 'would-update', name, data: {} };
209
+ }
117
210
  const createArgs = isRole
118
211
  ? { action: 'create_skill', role: options.role, name, description, body, properties, references }
119
212
  : { action: 'create', name, description, body, properties, references };
@@ -122,11 +215,9 @@ async function skillPushWithConfig(dir, options, config) {
122
215
  result = await (0, mcp_1.callCrewsTool)(config, tool, createArgs);
123
216
  }
124
217
  catch (e) {
125
- process.stderr.write(chalk_1.default.red(`error: MCP request failed — ${e.message}\n`));
126
- process.exit(1);
218
+ throw new Error(`MCP request failed — ${e.message}`);
127
219
  }
128
- let didUpdate = false;
129
- // 6. On name collision, switch to the edit path (the upsert). properties → properties_patch.
220
+ // On name collision, switch to the edit path. properties → properties_patch.
130
221
  if (!result.ok && result.data?.code === 'name_collision') {
131
222
  const editArgs = isRole
132
223
  ? { action: 'edit_skill', role: options.role, name, description, body, properties_patch: properties, references }
@@ -135,34 +226,186 @@ async function skillPushWithConfig(dir, options, config) {
135
226
  result = await (0, mcp_1.callCrewsTool)(config, tool, editArgs);
136
227
  }
137
228
  catch (e) {
138
- process.stderr.write(chalk_1.default.red(`error: MCP request failed — ${e.message}\n`));
139
- process.exit(1);
229
+ throw new Error(`MCP request failed — ${e.message}`);
140
230
  }
141
- didUpdate = true;
231
+ if (!result.ok) {
232
+ const errData = result.data;
233
+ const errCode = errData?.code ?? 'unknown_error';
234
+ const errMsg = errData?.message ?? 'MCP returned an error with no message';
235
+ throw new Error(`${errCode}: ${errMsg}`);
236
+ }
237
+ return { status: 'updated', name, data: result.data ?? {} };
142
238
  }
143
- // 7. Handle result.
144
239
  if (!result.ok) {
145
240
  const errData = result.data;
146
241
  const errCode = errData?.code ?? 'unknown_error';
147
242
  const errMsg = errData?.message ?? 'MCP returned an error with no message';
148
- process.stderr.write(chalk_1.default.red(`${errCode}: ${errMsg}\n`));
149
- process.exit(1);
243
+ throw new Error(`${errCode}: ${errMsg}`);
150
244
  }
245
+ return { status: 'created', name, data: result.data ?? {} };
246
+ }
247
+ /**
248
+ * Print a single skill push result line (shared between single and plugin modes).
249
+ */
250
+ function printPushResult(result, options) {
151
251
  if (options.json) {
152
252
  console.log(JSON.stringify(result.data));
153
- process.exit(0);
253
+ return;
154
254
  }
155
- const data = result.data;
156
- if (didUpdate) {
157
- // edit shape: {version_id, body_blob_sha}
255
+ const { status, name, data } = result;
256
+ if (status === 'would-create') {
257
+ console.log(chalk_1.default.cyan(`[dry-run] would create '${name}'`));
258
+ }
259
+ else if (status === 'would-update') {
260
+ console.log(chalk_1.default.cyan(`[dry-run] would update '${name}'`));
261
+ }
262
+ else if (status === 'updated') {
158
263
  console.log(chalk_1.default.green(`updated skill '${name}' (version ${data.version_id ?? '?'})`));
159
264
  }
160
265
  else {
161
- // create shape: {skill_doc_id, folder_id, reference_doc_ids}
162
266
  const skillDocId = data.skill_doc_id ?? data.doc_id ?? data.id ?? '?';
163
267
  const refCount = Object.keys(data.reference_doc_ids ?? {}).length;
164
268
  console.log(chalk_1.default.green(`created skill '${name}' (doc ${skillDocId}, ${refCount} refs)`));
165
269
  }
270
+ }
271
+ /**
272
+ * Core implementation — accepts an injected config so tests can point at a
273
+ * stub server without touching the filesystem config.
274
+ *
275
+ * Routes between single-skill mode (top-level SKILL.md) and plugin mode
276
+ * (skills/ + commands/ subdirectories) based on directory structure.
277
+ */
278
+ async function skillPushWithConfig(dir, options, config) {
279
+ const absDir = path_1.default.resolve(dir);
280
+ if (!fs_1.default.existsSync(absDir) || !fs_1.default.statSync(absDir).isDirectory()) {
281
+ process.stderr.write(chalk_1.default.red(`error: "${dir}" is not a directory.\n`));
282
+ process.exit(1);
283
+ }
284
+ const topLevelSkillMd = path_1.default.join(absDir, 'SKILL.md');
285
+ // -------------------------------------------------------------------------
286
+ // Single-skill mode: <dir>/SKILL.md exists
287
+ // -------------------------------------------------------------------------
288
+ if (fs_1.default.existsSync(topLevelSkillMd)) {
289
+ let payload;
290
+ try {
291
+ payload = readSkillDir(dir);
292
+ }
293
+ catch (e) {
294
+ process.stderr.write(chalk_1.default.red(`error: ${e.message}\n`));
295
+ process.exit(1);
296
+ }
297
+ let pushResult;
298
+ try {
299
+ pushResult = await pushParsedSkill(payload, options, config);
300
+ }
301
+ catch (e) {
302
+ process.stderr.write(chalk_1.default.red(`error: ${e.message}\n`));
303
+ process.exit(1);
304
+ }
305
+ if (options.json) {
306
+ console.log(JSON.stringify(pushResult.data));
307
+ }
308
+ else {
309
+ printPushResult(pushResult, options);
310
+ }
311
+ process.exit(0);
312
+ }
313
+ // -------------------------------------------------------------------------
314
+ // Plugin mode: push skills/<name>/SKILL.md + commands/<name>.md
315
+ // -------------------------------------------------------------------------
316
+ const payloads = [];
317
+ // One-level glob: skills/*/SKILL.md
318
+ const skillsDir = path_1.default.join(absDir, 'skills');
319
+ if (fs_1.default.existsSync(skillsDir) && fs_1.default.statSync(skillsDir).isDirectory()) {
320
+ for (const entry of fs_1.default.readdirSync(skillsDir, { withFileTypes: true })) {
321
+ if (!entry.isDirectory())
322
+ continue;
323
+ const skillSubdir = path_1.default.join(skillsDir, entry.name);
324
+ const skillMd = path_1.default.join(skillSubdir, 'SKILL.md');
325
+ if (!fs_1.default.existsSync(skillMd))
326
+ continue;
327
+ try {
328
+ payloads.push(readSkillDir(skillSubdir));
329
+ }
330
+ catch (e) {
331
+ process.stderr.write(chalk_1.default.red(`error: skills/${entry.name}/SKILL.md: ${e.message}\n`));
332
+ process.exit(1);
333
+ }
334
+ }
335
+ }
336
+ // One-level glob: commands/*.md
337
+ const commandsDir = path_1.default.join(absDir, 'commands');
338
+ if (fs_1.default.existsSync(commandsDir) && fs_1.default.statSync(commandsDir).isDirectory()) {
339
+ for (const entry of fs_1.default.readdirSync(commandsDir, { withFileTypes: true })) {
340
+ if (!entry.isFile())
341
+ continue;
342
+ if (!entry.name.endsWith('.md'))
343
+ continue;
344
+ const cmdPath = path_1.default.join(commandsDir, entry.name);
345
+ const content = fs_1.default.readFileSync(cmdPath, 'utf8');
346
+ try {
347
+ payloads.push(parseCommandFile(entry.name, content));
348
+ }
349
+ catch (e) {
350
+ process.stderr.write(chalk_1.default.red(`error: commands/${entry.name}: ${e.message}\n`));
351
+ process.exit(1);
352
+ }
353
+ }
354
+ }
355
+ if (payloads.length === 0) {
356
+ process.stderr.write(chalk_1.default.red(`error: no skills/ or commands/ under "${dir}" — nothing to push.\n`));
357
+ process.exit(1);
358
+ }
359
+ // Detect duplicate names BEFORE any push
360
+ const seen = new Map();
361
+ for (const p of payloads) {
362
+ seen.set(p.name, (seen.get(p.name) ?? 0) + 1);
363
+ }
364
+ const conflicts = [...seen.entries()].filter(([, count]) => count > 1).map(([name]) => name);
365
+ if (conflicts.length > 0) {
366
+ process.stderr.write(chalk_1.default.red(`error: duplicate skill names detected — aborting before any push.\n`) +
367
+ chalk_1.default.red(` conflicts: ${conflicts.join(', ')}\n`) +
368
+ chalk_1.default.red(` Rename the conflicting skills/commands so all names are unique.\n`));
369
+ process.exit(1);
370
+ }
371
+ // Push each skill, print a summary line per item
372
+ let createdCount = 0;
373
+ let updatedCount = 0;
374
+ for (const payload of payloads) {
375
+ let pushResult;
376
+ try {
377
+ pushResult = await pushParsedSkill(payload, options, config);
378
+ }
379
+ catch (e) {
380
+ process.stderr.write(chalk_1.default.red(`error: ${payload.name}: ${e.message}\n`));
381
+ process.exit(1);
382
+ }
383
+ printPushResult(pushResult, options);
384
+ if (pushResult.status === 'created' || pushResult.status === 'would-create') {
385
+ createdCount++;
386
+ }
387
+ else {
388
+ updatedCount++;
389
+ }
390
+ }
391
+ // Final tally
392
+ if (!options.json) {
393
+ const parts = [];
394
+ if (options.dryRun) {
395
+ if (createdCount > 0)
396
+ parts.push(`${createdCount} would create`);
397
+ if (updatedCount > 0)
398
+ parts.push(`${updatedCount} would update`);
399
+ console.log(chalk_1.default.cyan(`\n[dry-run] ${payloads.length} skill(s) (${parts.join(', ')})`));
400
+ }
401
+ else {
402
+ if (createdCount > 0)
403
+ parts.push(`${createdCount} created`);
404
+ if (updatedCount > 0)
405
+ parts.push(`${updatedCount} updated`);
406
+ console.log(chalk_1.default.cyan(`\ndone: ${payloads.length} skill(s) pushed (${parts.join(', ')})`));
407
+ }
408
+ }
166
409
  process.exit(0);
167
410
  }
168
411
  /**
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ /**
3
+ * solidactions skill view <name>
4
+ *
5
+ * Shows the full details of a single skill from the crews library.
6
+ */
7
+ var __importDefault = (this && this.__importDefault) || function (mod) {
8
+ return (mod && mod.__esModule) ? mod : { "default": mod };
9
+ };
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.skillViewWithConfig = skillViewWithConfig;
12
+ exports.skillView = skillView;
13
+ const chalk_1 = __importDefault(require("chalk"));
14
+ const api_1 = require("../utils/api");
15
+ const mcp_1 = require("../utils/mcp");
16
+ /**
17
+ * Core implementation — accepts an injected config so tests can point at a
18
+ * stub server without touching the filesystem config.
19
+ */
20
+ async function skillViewWithConfig(name, options, config) {
21
+ let result;
22
+ try {
23
+ result = await (0, mcp_1.callCrewsTool)(config, 'skills', { action: 'read', identifier: name });
24
+ }
25
+ catch (e) {
26
+ process.stderr.write(chalk_1.default.red(`error: ${e.message}\n`));
27
+ process.exit(1);
28
+ }
29
+ if (!result.ok) {
30
+ const code = result.data?.code ?? 'unknown_error';
31
+ const message = result.data?.message ?? 'MCP returned an error with no message';
32
+ process.stderr.write(chalk_1.default.red(`error: ${code}: ${message}\n`));
33
+ process.exit(1);
34
+ }
35
+ if (options.json) {
36
+ console.log(JSON.stringify(result.data));
37
+ process.exit(0);
38
+ }
39
+ const data = result.data ?? {};
40
+ const identifier = data.identifier ?? name;
41
+ const description = data.properties?.description ?? '';
42
+ const body = data.body ?? '';
43
+ console.log(chalk_1.default.green(identifier));
44
+ if (description) {
45
+ console.log(chalk_1.default.gray(description));
46
+ }
47
+ if (body) {
48
+ console.log('');
49
+ console.log(body);
50
+ }
51
+ process.exit(0);
52
+ }
53
+ /**
54
+ * Entry point called from index.ts.
55
+ */
56
+ async function skillView(name, options) {
57
+ const config = await (0, api_1.requireConfigWithWorkspace)();
58
+ await skillViewWithConfig(name, options, config);
59
+ }
package/dist/index.js CHANGED
@@ -33,6 +33,11 @@ const oauth_actions_list_1 = require("./commands/oauth-actions-list");
33
33
  const oauth_actions_show_1 = require("./commands/oauth-actions-show");
34
34
  const workspaces_1 = require("./commands/workspaces");
35
35
  const skill_push_1 = require("./commands/skill-push");
36
+ const skill_pull_1 = require("./commands/skill-pull");
37
+ const skill_list_1 = require("./commands/skill-list");
38
+ const skill_view_1 = require("./commands/skill-view");
39
+ const skill_delete_1 = require("./commands/skill-delete");
40
+ const role_push_1 = require("./commands/role-push");
36
41
  const config_1 = require("./utils/config");
37
42
  // eslint-disable-next-line @typescript-eslint/no-var-requires
38
43
  const pkg = require('../package.json');
@@ -402,9 +407,53 @@ skill
402
407
  .argument('<dir>', 'Path to the skill directory (must contain SKILL.md)')
403
408
  .option('--role <name>', 'Scope the skill to a role instead of the shared library')
404
409
  .option('--json', 'Output result as JSON')
410
+ .option('--dry-run', 'Preview create vs update without writing')
405
411
  .action(async (dir, options) => {
406
412
  await (0, skill_push_1.skillPush)(dir, options);
407
413
  });
414
+ skill
415
+ .command('pull')
416
+ .description('Fetch a skill from the library to a local folder for editing (inverse of push)')
417
+ .argument('<name>', 'Skill name or identifier')
418
+ .argument('[dest]', 'Destination directory (defaults to ./<name>/)')
419
+ .option('--json', 'Output raw read result as JSON (no file writes)')
420
+ .action(async (name, dest, options) => {
421
+ await (0, skill_pull_1.skillPull)(name, dest, options);
422
+ });
423
+ skill
424
+ .command('list')
425
+ .description('List skills in the library')
426
+ .option('--json', 'Output as JSON')
427
+ .option('--limit <n>', 'Maximum number of skills to return', (v) => parseInt(v, 10))
428
+ .action(async (options) => {
429
+ await (0, skill_list_1.skillList)(options);
430
+ });
431
+ skill
432
+ .command('view <name>')
433
+ .description('Show one skill')
434
+ .option('--json', 'Output as JSON')
435
+ .action(async (name, options) => {
436
+ await (0, skill_view_1.skillView)(name, options);
437
+ });
438
+ skill
439
+ .command('delete <name>')
440
+ .description('Delete a skill (Admin only)')
441
+ .option('--json', 'Output as JSON')
442
+ .action(async (name, options) => {
443
+ await (0, skill_delete_1.skillDelete)(name, options);
444
+ });
445
+ // =============================================================================
446
+ // role <subcommand> (SOP surface — flat peer of skill, per cli#34)
447
+ // =============================================================================
448
+ const role = program.command('role').description('Manage roles (crews SOP surface)');
449
+ role
450
+ .command('push <dir>')
451
+ .description('Push a role definition (create or update)')
452
+ .option('--dry-run', 'Preview create vs update without writing')
453
+ .option('--json', 'Output result as JSON')
454
+ .action(async (dir, options) => {
455
+ await (0, role_push_1.rolePush)(dir, options);
456
+ });
408
457
  program.parseAsync().catch((err) => {
409
458
  console.error(chalk_1.default.red(err.message ?? String(err)));
410
459
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidactions/cli",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "description": "SolidActions CLI - Deploy and manage workflow automation",
5
5
  "main": "dist/index.js",
6
6
  "bin": {