@maccesar/titools 4.5.0 → 4.6.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.
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import chalk from 'chalk';
7
- import { existsSync, lstatSync, readFileSync, realpathSync } from 'fs';
7
+ import { existsSync, lstatSync, readFileSync } from 'fs';
8
8
  import { join } from 'path';
9
9
  import os from 'os';
10
10
  import {
@@ -16,44 +16,144 @@ import {
16
16
  const CHECK = chalk.green('✓');
17
17
  const CROSS = chalk.red('✗');
18
18
 
19
+ // Two lines of description per skill: enough to tell what a skill is for,
20
+ // short enough that the list stays a list. Everything past that is the
21
+ // skill's trigger text, which is written for the agent, not for this screen.
22
+ const MAX_DESCRIPTION_LINES = 2;
23
+
24
+ // Descriptions still need room to say something when the window is narrow.
25
+ const MIN_DESCRIPTION_WIDTH = 24;
26
+
27
+ /**
28
+ * Unquote a YAML scalar. Most skills here are single-quoted because the
29
+ * descriptions contain colons and apostrophes; without this, the quote is
30
+ * printed as part of the text.
31
+ * @param {string} value - Raw scalar as it appears after `description:`
32
+ * @returns {string} The value with its wrapping quotes and escapes resolved
33
+ */
34
+ function unquote(value) {
35
+ const text = value.trim();
36
+ const quote = text[0];
37
+ if ((quote !== "'" && quote !== '"') || !text.endsWith(quote) || text.length < 2) {
38
+ return text;
39
+ }
40
+ const inner = text.slice(1, -1);
41
+ // In single-quoted YAML an apostrophe is escaped by doubling it.
42
+ return quote === "'" ? inner.replace(/''/g, "'") : inner.replace(/\\"/g, '"');
43
+ }
44
+
19
45
  /**
20
- * Extract the skill's human-readable name and short description from SKILL.md.
21
- * Returns { description, installed } where description is the first sentence of
22
- * the frontmatter description, trimmed for terminal display.
46
+ * Wrap text into aligned lines, capped at maxLines with an ellipsis when it
47
+ * does not fit.
48
+ * @param {string} text - Text to wrap
49
+ * @param {number} width - Columns available for the text itself
50
+ * @param {number} maxLines - Hard cap on the number of lines
51
+ * @returns {string[]} Lines, none longer than width
23
52
  */
24
- function readSkillMetadata(skillDir) {
25
- const skillMd = join(skillDir, 'SKILL.md');
26
- if (!existsSync(skillMd)) {
27
- return { description: null, installed: false };
53
+ export function wrapDescription(text, width, maxLines = MAX_DESCRIPTION_LINES) {
54
+ const columns = Math.max(width, MIN_DESCRIPTION_WIDTH);
55
+ const lines = [];
56
+ let current = '';
57
+
58
+ for (let word of text.split(/\s+/).filter(Boolean)) {
59
+ // A single word wider than the column (a long URL) is broken by hand;
60
+ // otherwise the loop below could never place it and would spin.
61
+ while (word.length > columns) {
62
+ if (current) {
63
+ lines.push(current);
64
+ current = '';
65
+ }
66
+ lines.push(word.slice(0, columns));
67
+ word = word.slice(columns);
68
+ }
69
+
70
+ if (!current) {
71
+ current = word;
72
+ } else if (current.length + 1 + word.length <= columns) {
73
+ current += ` ${word}`;
74
+ } else {
75
+ lines.push(current);
76
+ current = word;
77
+ }
78
+ }
79
+ if (current) lines.push(current);
80
+
81
+ if (lines.length <= maxLines) return lines;
82
+
83
+ const kept = lines.slice(0, maxLines);
84
+ let last = kept[maxLines - 1];
85
+ if (last.length + 1 > columns) {
86
+ last = last.slice(0, columns - 1).replace(/\s+\S*$/, '');
28
87
  }
88
+ kept[maxLines - 1] = `${last.replace(/[,;:]$/, '')}…`;
89
+ return kept;
90
+ }
91
+
92
+ /**
93
+ * Extract the skill's short description from SKILL.md — the first sentence of
94
+ * the frontmatter description, unquoted and collapsed to a single line.
95
+ *
96
+ * The sentence ends at a period followed by whitespace, which avoids breaking
97
+ * on "SDK 13." and other version-number periods.
98
+ * @param {string} frontmatter - The YAML block between the --- fences
99
+ * @returns {string|null} The description, or null when there is none
100
+ */
101
+ export function parseDescription(frontmatter) {
102
+ // The lookahead ends the value at the next top-level key, or at the end of
103
+ // the block. `(?![\s\S])` rather than `$`, which under the /m flag would
104
+ // match the first newline and cut a description written across two lines.
105
+ const descMatch = frontmatter.match(/^description:\s*([\s\S]+?)(?=\n[A-Za-z][\w-]*:|(?![\s\S]))/m);
106
+ if (!descMatch) return null;
107
+
108
+ const full = unquote(descMatch[1]).replace(/\s+/g, ' ').trim();
109
+ if (!full) return null;
110
+
111
+ const firstSentence = full.match(/^([^]*?\.)\s/);
112
+ if (firstSentence) return firstSentence[1].trim();
113
+ return full;
114
+ }
29
115
 
116
+ /**
117
+ * Read a description out of one SKILL.md.
118
+ * @param {string} skillMd - Path to a SKILL.md
119
+ * @returns {string|null} The first sentence, or null when unreadable
120
+ */
121
+ function readDescription(skillMd) {
30
122
  try {
31
123
  const content = readFileSync(skillMd, 'utf8');
32
124
  const frontmatter = content.match(/^---\n([\s\S]*?)\n---/);
33
- if (!frontmatter) return { description: null, installed: true };
34
-
35
- const descMatch = frontmatter[1].match(/description:\s*"([^"]+)"|description:\s*(.+)/);
36
- if (!descMatch) return { description: null, installed: true };
37
-
38
- const full = descMatch[1] || descMatch[2] || '';
39
- // First sentence: period followed by whitespace (avoids breaking on "SDK 13."
40
- // or other version-number periods). Falls back to a word-safe length cutoff.
41
- let short;
42
- const firstSentence = full.match(/^([^]*?\.)\s/);
43
- if (firstSentence) {
44
- short = firstSentence[1];
45
- } else if (full.length > 80) {
46
- const cut = full.slice(0, 80);
47
- short = cut.slice(0, cut.lastIndexOf(' ')) + '…';
48
- } else {
49
- short = full;
50
- }
51
- return { description: short.trim(), installed: true };
125
+ if (!frontmatter) return null;
126
+ return parseDescription(frontmatter[1]);
52
127
  } catch {
53
- return { description: null, installed: true };
128
+ return null;
54
129
  }
55
130
  }
56
131
 
132
+ /**
133
+ * Read a skill's description, preferring the installed copy and falling back
134
+ * to the one bundled in this package.
135
+ *
136
+ * The fallback is what lets the list describe a skill that is not installed
137
+ * yet — which is exactly when someone runs `titools list` and needs to know
138
+ * what they would be installing. `skills/` ships in the npm tarball, so the
139
+ * bundled copy is there for every install channel.
140
+ * @param {string} skillDir - Where the skill would be installed
141
+ * @param {string} name - Skill name, used to find the bundled copy
142
+ * @returns {{description: string|null, installed: boolean}}
143
+ */
144
+ function readSkillMetadata(skillDir, name) {
145
+ const installedMd = join(skillDir, 'SKILL.md');
146
+ if (existsSync(installedMd)) {
147
+ return { description: readDescription(installedMd), installed: true };
148
+ }
149
+
150
+ const bundledMd = new URL(`../../skills/${name}/SKILL.md`, import.meta.url);
151
+ return {
152
+ description: existsSync(bundledMd) ? readDescription(bundledMd) : null,
153
+ installed: false,
154
+ };
155
+ }
156
+
57
157
  export async function listCommand() {
58
158
  console.log('');
59
159
  console.log(chalk.bold.blue(`Titanium skills (v${PACKAGE_VERSION})`));
@@ -61,21 +161,13 @@ export async function listCommand() {
61
161
 
62
162
  const skillsDir = getAgentsSkillsDir();
63
163
 
64
- if (!existsSync(skillsDir)) {
65
- console.log(chalk.yellow('No skills installed yet.'));
66
- console.log('Install with:');
67
- console.log(chalk.cyan(' titools install'));
68
- console.log('');
69
- return;
70
- }
71
-
72
164
  const rows = [];
73
165
  let installedCount = 0;
74
166
  let maxNameLen = 0;
75
167
 
76
168
  for (const name of SKILLS) {
77
169
  const skillDir = join(skillsDir, name);
78
- const { description, installed } = readSkillMetadata(skillDir);
170
+ const { description, installed } = readSkillMetadata(skillDir, name);
79
171
 
80
172
  if (installed) installedCount++;
81
173
  if (name.length > maxNameLen) maxNameLen = name.length;
@@ -83,18 +175,37 @@ export async function listCommand() {
83
175
  rows.push({ name, description, installed });
84
176
  }
85
177
 
178
+ // Where the description column starts: ' ' + mark + ' ' + padded name + ' '.
179
+ // Measured on the plain text, since chalk's colour codes take no columns.
180
+ const descriptionColumn = 2 + 1 + 1 + (maxNameLen + 2) + 1;
181
+ const available = (process.stdout.columns || 80) - descriptionColumn;
182
+
86
183
  // Print aligned rows
87
184
  for (const row of rows) {
88
185
  const mark = row.installed ? CHECK : CROSS;
89
186
  const paddedName = row.name.padEnd(maxNameLen + 2);
90
- const desc = row.description
91
- ? chalk.gray(row.description)
92
- : chalk.gray(row.installed ? '(no description)' : 'not installed');
187
+ const text = row.description || (row.installed ? '(no description)' : 'not installed');
188
+ const [first, ...rest] = wrapDescription(text, available);
93
189
 
94
- console.log(` ${mark} ${chalk.cyan(paddedName)} ${desc}`);
190
+ console.log(` ${mark} ${chalk.cyan(paddedName)} ${chalk.gray(first)}`);
191
+ for (const line of rest) {
192
+ console.log(`${' '.repeat(descriptionColumn)}${chalk.gray(line)}`);
193
+ }
95
194
  }
96
195
 
97
196
  console.log('');
197
+
198
+ if (installedCount === 0) {
199
+ // Naming the directory would point at somewhere that does not exist yet.
200
+ console.log(chalk.gray(`0/${SKILLS.length} installed`));
201
+ console.log('');
202
+ console.log(chalk.yellow('No skills installed yet.'));
203
+ console.log('Install with:');
204
+ console.log(chalk.cyan(' titools install'));
205
+ console.log('');
206
+ return;
207
+ }
208
+
98
209
  console.log(chalk.gray(`${installedCount}/${SKILLS.length} installed at ${skillsDir}`));
99
210
  console.log('');
100
211
  console.log(chalk.gray('Run `titools status` for installation health.'));
@@ -8,7 +8,6 @@ import ora from 'ora';
8
8
  import {
9
9
  SKILLS,
10
10
  getPlatforms,
11
- getAgentsSkillsDir,
12
11
  } from '../config.js';
13
12
  import select from '../prompts/selectCancel.js';
14
13
  import checkbox, { Separator } from '../prompts/checkboxCancel.js';
@@ -38,7 +37,6 @@ import { downloadRepoArchive } from '../downloader.js';
38
37
  import { createSkillSymlinks } from '../symlink.js';
39
38
  import { installHook } from '../hooks.js';
40
39
  import {
41
- formatList,
42
40
  isTitaniumProject,
43
41
  readSkillDescription,
44
42
  shortenSkillDescription,
@@ -305,7 +303,7 @@ export async function skillsCommand(options) {
305
303
  if (!removeOnly) {
306
304
  // Install skills
307
305
  spinner.start('Installing skills...');
308
- const skillsResult = await installSkills(repoDir, baseDir, skillsToInstall);
306
+ await installSkills(repoDir, baseDir, skillsToInstall);
309
307
  spinner.succeed(`${skillsToInstall.length} skill${skillsToInstall.length !== 1 ? 's' : ''} installed`);
310
308
 
311
309
  // If the user deselected some skills, sweep any previously-installed
@@ -415,7 +413,6 @@ export async function skillsCommand(options) {
415
413
  removedCount: symlinkResult.removed.length,
416
414
  };
417
415
  });
418
- const anyRemoved = platformResults.some((result) => result.removedCount > 0);
419
416
 
420
417
  if (skillsResult.removed.length > 0) {
421
418
  console.log(chalk.green('✓'), `${skillsResult.removed.length} skills removed`);
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import chalk from 'chalk';
7
- import { existsSync, readdirSync, lstatSync, readFileSync } from 'fs';
7
+ import { existsSync, lstatSync, readFileSync } from 'fs';
8
8
  import { join } from 'path';
9
9
  import os from 'os';
10
10
  import {
@@ -14,7 +14,6 @@ import {
14
14
  getClaudeAgentsDir,
15
15
  getConfigDir,
16
16
  getPlatforms,
17
- BLOCK_START,
18
17
  } from '../config.js';
19
18
  import { hasHook } from '../hooks.js';
20
19
  import { readLastCheck } from '../cache.js';
@@ -27,7 +27,7 @@ import {
27
27
  fetchLatestNpmVersion,
28
28
  } from '../downloader.js';
29
29
  import { createSkillSymlinks } from '../symlink.js';
30
- import { formatList, isTitaniumProject } from '../utils.js';
30
+ import { isTitaniumProject } from '../utils.js';
31
31
  import { getAgentsSkillsDir } from '../config.js';
32
32
  import { existsSync } from 'fs';
33
33
  import { join, resolve } from 'path';
package/lib/installer.js CHANGED
@@ -7,7 +7,6 @@ import {
7
7
  copyFileSync,
8
8
  existsSync,
9
9
  mkdirSync,
10
- readdirSync,
11
10
  } from 'fs';
12
11
  import { join } from 'path';
13
12
  import { remove, copy } from 'fs-extra';
@@ -49,12 +49,6 @@ function toggle(item) {
49
49
  return isSelectable(item) ? { ...item, checked: !item.checked } : item;
50
50
  }
51
51
 
52
- function check(checked) {
53
- return function (item) {
54
- return isSelectable(item) ? { ...item, checked } : item;
55
- };
56
- }
57
-
58
52
  function normalizeChoices(choices) {
59
53
  return choices.map((choice) => {
60
54
  if (Separator.isSeparator(choice)) return choice;
package/lib/symlink.js CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { mkdirSync, existsSync } from 'fs';
7
- import { symlink, readlink, unlink, lstat } from 'fs/promises';
7
+ import { symlink, readlink } from 'fs/promises';
8
8
  import { join, dirname, relative } from 'path';
9
9
  import { copy, remove } from 'fs-extra';
10
10
  import { isWindows } from './platform.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maccesar/titools",
3
- "version": "4.5.0",
3
+ "version": "4.6.1",
4
4
  "description": "Titanium SDK skills and agents for AI coding assistants (Claude Code, Gemini CLI, Codex CLI)",
5
5
  "main": "lib/index.js",
6
6
  "type": "module",
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "scripts": {
11
11
  "test": "node --test test/**/*.test.js",
12
- "lint": "eslint lib/**/*.js",
12
+ "lint": "eslint bin lib test",
13
13
  "format": "prettier --write lib/**/*.js"
14
14
  },
15
15
  "keywords": [
@@ -49,7 +49,9 @@
49
49
  "tar": "^7.5.7"
50
50
  },
51
51
  "devDependencies": {
52
+ "@eslint/js": "^9.39.5",
52
53
  "eslint": "^9.39.2",
54
+ "globals": "^16.5.0",
53
55
  "prettier": "^3.8.1"
54
56
  },
55
57
  "files": [
@@ -427,7 +427,8 @@ Load these only when needed:
427
427
  - [iOS Large Titles](references/ios-large-titles.md) - Best practice for iOS Large Title navigation
428
428
 
429
429
  ### App Assets
430
- - [App Branding](references/app-branding.md) - `brand` command for launcher icons, adaptive, iOS 18+ Dark/Tinted, marketplace assets (v7.6.0)
430
+ - [App Branding](references/app-branding.md) - `brand` command: the 14 pieces it regenerates (launcher, adaptive, iOS 18+ Dark/Tinted, marketplace, both splash sets), the per-piece `brand:` config, `--only`, `--optimize` (v7.13.0)
431
+ - [Launch Background](references/launch-background.md) - Carrying `brand.background` into the iOS LaunchScreen and a launcher-only Android theme (`--notes` output)
431
432
  - [Multi-Density Images](references/multi-density-images.md) - `images` command for Android res-*dpi + iPhone @1x/@2x/@3x UI images (v7.6.0)
432
433
  - [SVG Pipeline](references/svg-pipeline.md) - SVG-aware compile-time image pipeline: `.svg` refs + numeric `w-*`/`h-*` classes compile to 8 density PNGs; `images.files`/`images.autoSync` (v7.11.0–v7.11.1)
433
434
 
@@ -444,7 +445,7 @@ Load these only when needed:
444
445
  - [Animation Advanced](references/animation-advanced.md) - Property inheritance, utility classes, implementation rules, complex UI example
445
446
 
446
447
  ### Release Notes
447
- - [Version History](references/version-history.md) - Release-by-release feature additions and behavior changes (v7.4.0 → v7.11.1)
448
+ - [Version History](references/version-history.md) - Release-by-release feature additions and behavior changes (v7.4.0 → v7.13.1)
448
449
 
449
450
  > **💡 TEXT FONTS (Google Fonts, Roboto, etc.)**
450
451
  > For text fonts, see [Custom Fonts](references/custom-fonts.md).