@qzhuli/qzhuli-cli 0.3.0 → 0.4.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,6 +23,41 @@ npm install @qzhuli/qzhuli-cli
23
23
  npx qz --version
24
24
  ```
25
25
 
26
+ ## AI Agent Skill
27
+
28
+ The CLI ships with a `SKILL.md` that teaches AI agents how to use it. During `npm install -g`, the skill is automatically installed to your agent's skill directory.
29
+
30
+ ### Supported agents (18)
31
+
32
+ Claude Code, Cursor, Codex, Copilot, Windsurf, OpenClaw, OpenCode, Cline, Gemini CLI, Amp, Roo, Goose, Kiro, Qwen Code, Qoder, Trae, Augment, Zed.
33
+
34
+ ### Manual skill install
35
+
36
+ If the automatic install didn't work (e.g. local `npm install` without `-g`):
37
+
38
+ ```bash
39
+ # Install to all detected agents (global)
40
+ npx @qzhuli/qzhuli-cli scripts/install-skill.mjs
41
+
42
+ # Install to current project's skill directory
43
+ npx @qzhuli/qzhuli-cli scripts/install-skill.mjs --project
44
+
45
+ # Install to a specific agent only
46
+ npx @qzhuli/qzhuli-cli scripts/install-skill.mjs --agent claude-code
47
+
48
+ # Update existing installs if skill version changed
49
+ npx @qzhuli/qzhuli-cli scripts/install-skill.mjs --update
50
+ ```
51
+
52
+ ### Updating
53
+
54
+ ```bash
55
+ npm update -g @qzhuli/qzhuli-cli
56
+ npx @qzhuli/qzhuli-cli scripts/install-skill.mjs --update
57
+ ```
58
+
59
+ The postinstall script auto-installs the skill on `npm install -g`. Use the `npx` command above to update skills without reinstalling the whole package.
60
+
26
61
  ## Commands
27
62
 
28
63
  ```bash
package/dist/cmd.js CHANGED
@@ -14807,7 +14807,7 @@ async function main() {
14807
14807
  ${t("cli.banner")}` : t("cli.banner");
14808
14808
  program.addHelpText("beforeAll", `${banner}
14809
14809
  `);
14810
- program.name("qz").version(`v${"0.3.0"}`, "-v, --version", t("options.version")).helpOption("-h, --help", t("options.help")).option("-q, --jq <expr>", t("options.jq")).option("--dry-run", t("options.dryRun"));
14810
+ program.name("qz").version(`v${"0.4.0-rc.2"}`, "-v, --version", t("options.version")).helpOption("-h, --help", t("options.help")).option("-q, --jq <expr>", t("options.jq")).option("--dry-run", t("options.dryRun"));
14811
14811
  program.usage("<command> [subcommand] [options]");
14812
14812
  program.hook("preAction", () => {
14813
14813
  const opts = program.opts();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qzhuli/qzhuli-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0-rc.2",
4
4
  "description": "CLI tool for Q助理 (QZhuli)",
5
5
  "main": "dist/cmd.js",
6
6
  "bin": {
@@ -9,6 +9,8 @@
9
9
  "files": [
10
10
  "dist",
11
11
  "scripts/postinstall.mjs",
12
+ "scripts/install-skill.mjs",
13
+ "scripts/skill-agent.mjs",
12
14
  "skills",
13
15
  "README.md",
14
16
  "CONTRIBUTING.md"
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Standalone skill installer for @qzhuli/qzhuli-cli
3
+ *
4
+ * Usage:
5
+ * node scripts/install-skill.mjs # install to all detected agents (global)
6
+ * node scripts/install-skill.mjs --project # install to current project's skills dir
7
+ * node scripts/install-skill.mjs --agent claude-code # install to specific agent only
8
+ * node scripts/install-skill.mjs --help # show help
9
+ *
10
+ * Works with:
11
+ * - Published npm package: `npx @qzhuli/qzhuli-cli scripts/install-skill.mjs`
12
+ * - Dev/source checkout: `node scripts/install-skill.mjs` (from repo root)
13
+ */
14
+
15
+ import { existsSync, readFileSync } from 'node:fs';
16
+ import { dirname, join, resolve } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { AGENT_PATHS, SKILL_NAME, resolveHome, copyDir, isInstalled, detectAgents, writeManifest, needsUpdate, readManifest } from './skill-agent.mjs';
19
+
20
+ const __filename = fileURLToPath(import.meta.url);
21
+ const __dirname = dirname(__filename);
22
+
23
+ // ── Helpers ────────────────────────────────────────────────────────
24
+
25
+ function findSourceDir() {
26
+ // Case 1: running from repo root (dev checkout)
27
+ const repoSkills = resolve(__dirname, '..', 'skills', SKILL_NAME);
28
+ if (existsSync(join(repoSkills, 'SKILL.md'))) return repoSkills;
29
+
30
+ // Case 2: running from npm package (node_modules)
31
+ const pkgSkills = resolve(__dirname, '..', '..', 'skills', SKILL_NAME);
32
+ if (existsSync(join(pkgSkills, 'SKILL.md'))) return pkgSkills;
33
+
34
+ // Case 3: skills dir is at package root (flattened install)
35
+ const flatSkills = resolve(__dirname, '..', 'skills');
36
+ if (existsSync(join(flatSkills, SKILL_NAME, 'SKILL.md'))) return join(flatSkills, SKILL_NAME);
37
+
38
+ return null;
39
+ }
40
+
41
+ function getPkgVersion() {
42
+ try {
43
+ const pkgJson = resolve(__dirname, '..', 'package.json');
44
+ const pkg = JSON.parse(readFileSync(pkgJson, 'utf8'));
45
+ return pkg.version || 'unknown';
46
+ } catch {
47
+ return 'unknown';
48
+ }
49
+ }
50
+
51
+ // ── CLI parsing ────────────────────────────────────────────────────
52
+
53
+ function parseArgs(argv) {
54
+ const args = { project: false, agent: null, help: false, update: false };
55
+ for (let i = 0; i < argv.length; i++) {
56
+ switch (argv[i]) {
57
+ case '--project':
58
+ args.project = true;
59
+ break;
60
+ case '--agent':
61
+ args.agent = argv[++i];
62
+ break;
63
+ case '--update':
64
+ args.update = true;
65
+ break;
66
+ case '--help':
67
+ case '-h':
68
+ args.help = true;
69
+ break;
70
+ }
71
+ }
72
+ return args;
73
+ }
74
+
75
+ function printHelp() {
76
+ console.log(`
77
+ Usage: node install-skill.mjs [options]
78
+
79
+ Options:
80
+ --project Install to current project's .claude/skills (or equivalent)
81
+ --agent <name> Install to a specific agent only (e.g. claude-code, cursor)
82
+ --update Check for updates and reinstall if version changed
83
+ --help, -h Show this help message
84
+
85
+ Examples:
86
+ node scripts/install-skill.mjs # global, all agents
87
+ node scripts/install-skill.mjs --project # project-level, all agents
88
+ node scripts/install-skill.mjs --agent claude-code # global, claude-code only
89
+ node scripts/install-skill.mjs --update # update all installed agents
90
+ npx @qzhuli/qzhuli-cli scripts/install-skill.mjs # from published npm
91
+ `);
92
+ }
93
+
94
+ // ── Main ───────────────────────────────────────────────────────────
95
+
96
+ function main() {
97
+ const opts = parseArgs(process.argv.slice(2));
98
+ if (opts.help) { printHelp(); process.exit(0); }
99
+
100
+ const sourceDir = findSourceDir();
101
+ if (!sourceDir) {
102
+ console.error('Error: Cannot find skill source directory.');
103
+ console.error('Run this script from the repo root, or install the npm package first.');
104
+ process.exit(1);
105
+ }
106
+
107
+ const pkgVersion = getPkgVersion();
108
+
109
+ // Resolve target agents
110
+ let targets = [];
111
+ if (opts.update) {
112
+ // --update: check agents that already have the skill installed
113
+ const candidates = opts.agent
114
+ ? [AGENT_PATHS.find(a => a.name === opts.agent)]
115
+ : AGENT_PATHS;
116
+
117
+ if (opts.agent && !candidates[0]) {
118
+ console.error(`Error: Unknown agent "${opts.agent}".`);
119
+ console.error('Supported agents:', AGENT_PATHS.map(a => a.name).join(', '));
120
+ process.exit(1);
121
+ }
122
+
123
+ for (const cfg of candidates) {
124
+ if (!cfg) continue;
125
+ const globalPath = resolveHome(cfg.global);
126
+ const skillTarget = join(globalPath, SKILL_NAME);
127
+ if (existsSync(skillTarget) && needsUpdate(skillTarget, sourceDir)) {
128
+ targets.push({ cfg, isProject: false });
129
+ }
130
+ if (opts.project) {
131
+ const projectPath = join(process.cwd(), cfg.project);
132
+ const projectTarget = join(projectPath, SKILL_NAME);
133
+ if (existsSync(projectTarget) && needsUpdate(projectTarget, sourceDir)) {
134
+ targets.push({ cfg, isProject: true });
135
+ }
136
+ }
137
+ }
138
+ if (targets.length === 0) {
139
+ console.log('All skills are up to date.');
140
+ return;
141
+ }
142
+ } else if (opts.agent) {
143
+ const cfg = AGENT_PATHS.find(a => a.name === opts.agent);
144
+ if (!cfg) {
145
+ console.error(`Error: Unknown agent "${opts.agent}".`);
146
+ console.error('Supported agents:', AGENT_PATHS.map(a => a.name).join(', '));
147
+ process.exit(1);
148
+ }
149
+ targets.push({ cfg, isProject: opts.project });
150
+ } else {
151
+ const detected = detectAgents();
152
+ const fallback = AGENT_PATHS.find(a => a.name === 'claude-code');
153
+ if (detected.length === 0) {
154
+ targets.push({ cfg: fallback, isProject: false });
155
+ } else {
156
+ for (const name of detected) {
157
+ const cfg = AGENT_PATHS.find(a => a.name === name);
158
+ if (cfg) targets.push({ cfg, isProject: false });
159
+ }
160
+ }
161
+ }
162
+
163
+ let installed = 0;
164
+ let skipped = 0;
165
+ let updated = 0;
166
+
167
+ for (const target of targets) {
168
+ const agentConfig = target.cfg;
169
+ const targetPath = target.isProject
170
+ ? join(process.cwd(), agentConfig.project)
171
+ : resolveHome(agentConfig.global);
172
+
173
+ const skillTarget = join(targetPath, SKILL_NAME);
174
+
175
+ if (!opts.update && isInstalled(skillTarget, sourceDir)) {
176
+ console.log(` (skip) ${agentConfig.name}: already up to date`);
177
+ skipped++;
178
+ continue;
179
+ }
180
+
181
+ copyDir(sourceDir, skillTarget);
182
+ writeManifest(skillTarget, sourceDir, pkgVersion);
183
+
184
+ if (opts.update) {
185
+ const manifest = readManifest(skillTarget);
186
+ console.log(` ✓ ${agentConfig.name}: updated to skill v${manifest?.skill_version || '?'}`);
187
+ updated++;
188
+ } else {
189
+ console.log(` ✓ ${agentConfig.name}: ${targetPath}`);
190
+ installed++;
191
+ }
192
+ }
193
+
194
+ const total = installed + updated;
195
+ console.log(`\nInstalled: ${installed}, Updated: ${updated}, Skipped: ${skipped}`);
196
+ if (total === 0 && skipped === 0) {
197
+ console.log('No targets found. Use --agent to specify one manually.');
198
+ }
199
+ }
200
+
201
+ main();
@@ -6,6 +6,7 @@
6
6
  * Features:
7
7
  * - Detects installed agents by checking home/project config dirs
8
8
  * - Copies the skill to each agent's skill directory (global + project)
9
+ * - Writes a manifest file for version tracking and future updates
9
10
  * - Skips in CI environments (CI, CONTINUOUS_INTEGRATION env vars)
10
11
  * - Skips if AX_SKIP_SKILL_INSTALL=1
11
12
  * - Silent failure — never breaks npm install
@@ -13,62 +14,14 @@
13
14
  * - Content-dedup: skips if identical file already exists
14
15
  */
15
16
 
16
- import {
17
- existsSync,
18
- readdirSync,
19
- readFileSync,
20
- copyFileSync,
21
- mkdirSync,
22
- rmSync,
23
- statSync,
24
- } from 'node:fs';
17
+ import { existsSync, readFileSync } from 'node:fs';
25
18
  import { dirname, join, resolve, sep } from 'node:path';
26
19
  import { fileURLToPath } from 'node:url';
27
- import { homedir } from 'node:os';
20
+ import { AGENT_PATHS, SKILL_NAME, resolveHome, copyDir, isInstalled, detectAgents, writeManifest } from './skill-agent.mjs';
28
21
 
29
22
  const __filename = fileURLToPath(import.meta.url);
30
23
  const __dirname = dirname(__filename);
31
24
 
32
- // ── Agent path mapping ─────────────────────────────────────────────
33
- // Source: https://www.agentskills.in/docs/getting-started
34
- const AGENT_PATHS = [
35
- { name: 'claude-code', global: '~/.claude/skills', project: '.claude/skills' },
36
- { name: 'cursor', global: '~/.cursor/skills', project: '.cursor/skills' },
37
- { name: 'codex', global: '~/.codex/skills', project: '.codex/skills' },
38
- { name: 'copilot', global: '~/.copilot/skills', project: '.github/skills' },
39
- { name: 'windsurf', global: '~/.codeium/windsurf/skills', project: '.windsurf/skills' },
40
- { name: 'openclaw', global: '~/.openclaw/skills', project: 'skills' },
41
- { name: 'opencode', global: '~/.config/opencode/skill',project: '.opencode/skill' },
42
- { name: 'cline', global: '~/.cline/skills', project: '.cline/skills' },
43
- { name: 'gemini-cli', global: '~/.gemini/skills', project: '.gemini/skills' },
44
- { name: 'amp', global: '~/.config/agents/skills', project: '.agents/skills' },
45
- { name: 'roo', global: '~/.roo/skills', project: '.roo/skills' },
46
- { name: 'goose', global: '~/.config/goose/skills', project: '.goose/skills' },
47
- { name: 'kiro', global: '~/.kiro/skills', project: '.kiro/skills' },
48
- { name: 'qwen-code', global: '~/.qwen/skills', project: '.qwen/skills' },
49
- { name: 'qoder', global: '~/.qoder/skills', project: '.qoder/skills' },
50
- { name: 'trae', global: '~/.trae/skills', project: '.trae/skills' },
51
- { name: 'augment', global: '~/.augment/skills', project: '.augment/skills' },
52
- { name: 'zed', global: '~/.config/zed/skills', project: '.zed/skills' },
53
- ];
54
-
55
- const SKILL_NAME = 'qzhuli-cli';
56
-
57
- // ── Helpers ────────────────────────────────────────────────────────
58
-
59
- function resolveHome(p) {
60
- if (p.startsWith('~/')) return join(homedir(), p.slice(2));
61
- return p;
62
- }
63
-
64
- function dirExists(p) {
65
- try {
66
- return statSync(resolveHome(p)).isDirectory();
67
- } catch {
68
- return false;
69
- }
70
- }
71
-
72
25
  function shouldSkip() {
73
26
  return (
74
27
  process.env.CI === 'true' ||
@@ -84,12 +37,6 @@ function isInteractive() {
84
37
  /**
85
38
  * Walk up from startDir to find the project root (where node_modules/@qzhuli/qzhuli-cli lives).
86
39
  * Returns null for global installs, or false for dev/source installs.
87
- *
88
- * Detection logic:
89
- * - If __dirname contains node_modules/@qzhuli/qzhuli-cli → it's an npm install
90
- * - If path matches global node_modules prefix → global install → return null
91
- * - Otherwise → local project install → return project root
92
- * - Otherwise → dev/source install → return false (skip skill install)
93
40
  */
94
41
  function findProjectRoot() {
95
42
  const scriptDir = resolve(__dirname, '..');
@@ -97,28 +44,18 @@ function findProjectRoot() {
97
44
  const nmIdx = normalized.lastIndexOf('node_modules/@qzhuli/qzhuli-cli');
98
45
 
99
46
  if (nmIdx === -1) {
100
- // Not installed via npm — this is a dev/source checkout, skip
101
- return false;
47
+ return false; // dev/source checkout, skip
102
48
  }
103
49
 
104
- // It's in node_modules. Determine if global or project-level.
105
50
  const projectRoot = normalized.slice(0, nmIdx).replace(/\/$/, '');
106
-
107
- // Global installs end up in paths like:
108
- // /usr/lib/node_modules/@qzhuli/qzhuli-cli
109
- // C:/Users/X/AppData/Roaming/npm/node_modules/@qzhuli/qzhuli-cli
110
- // /Users/X/.nvm/versions/node/v22.X/lib/node_modules/@qzhuli/qzhuli-cli
111
- // After slicing off node_modules/..., the projectRoot is either empty
112
- // or just a system prefix. We check if there's a package.json at projectRoot.
113
- if (!projectRoot) return null; // no prefix → treat as global
51
+ if (!projectRoot) return null;
114
52
 
115
53
  const pkgAtRoot = join(projectRoot.replace(/\//g, sep), 'package.json');
116
54
  if (!existsSync(pkgAtRoot)) return null;
117
55
 
118
56
  try {
119
57
  const pkg = JSON.parse(readFileSync(pkgAtRoot, 'utf8'));
120
- // If the project root's package.json is our own package, it's global
121
- if (pkg.name === '@qzhuli/qzhuli-cli') return null;
58
+ if (pkg.name === '@qzhuli/qzhuli-cli') return null; // global install
122
59
  } catch {
123
60
  return null;
124
61
  }
@@ -127,47 +64,15 @@ function findProjectRoot() {
127
64
  }
128
65
 
129
66
  /**
130
- * Detect which agents are installed by checking if their config dirs exist globally.
131
- */
132
- function detectAgents() {
133
- return AGENT_PATHS
134
- .filter(agent => dirExists(agent.global))
135
- .map(agent => agent.name);
136
- }
137
-
138
- /**
139
- * Recursively copy a directory.
140
- */
141
- function copyDir(src, dest) {
142
- mkdirSync(dest, { recursive: true });
143
- const entries = readdirSync(src, { withFileTypes: true });
144
- for (const entry of entries) {
145
- const srcPath = join(src, entry.name);
146
- const destPath = join(dest, entry.name);
147
- if (entry.isDirectory()) {
148
- copyDir(srcPath, destPath);
149
- } else {
150
- copyFileSync(srcPath, destPath);
151
- }
152
- }
153
- }
154
-
155
- /**
156
- * Check if a skill is already installed with identical content.
67
+ * Get the CLI package version from the nearest package.json.
157
68
  */
158
- function isInstalled(skillTarget, sourceDir) {
159
- if (!existsSync(skillTarget)) return false;
69
+ function getPkgVersion() {
160
70
  try {
161
- const srcFiles = readdirSync(sourceDir);
162
- const destFiles = readdirSync(skillTarget);
163
- if (srcFiles.length !== destFiles.length) return false;
164
- // Compare SKILL.md content (primary file)
165
- const srcMd = join(sourceDir, 'SKILL.md');
166
- const destMd = join(skillTarget, 'SKILL.md');
167
- if (!existsSync(destMd)) return false;
168
- return readFileSync(srcMd, 'utf8') === readFileSync(destMd, 'utf8');
71
+ const pkgJson = resolve(__dirname, '..', 'package.json');
72
+ const pkg = JSON.parse(readFileSync(pkgJson, 'utf8'));
73
+ return pkg.version || 'unknown';
169
74
  } catch {
170
- return false;
75
+ return 'unknown';
171
76
  }
172
77
  }
173
78
 
@@ -181,17 +86,16 @@ function install() {
181
86
  if (!existsSync(sourceDir)) return;
182
87
 
183
88
  const projectRoot = findProjectRoot();
184
- // false = dev/source install, skip
185
89
  if (projectRoot === false) { console.log('SKIP: dev install'); return; }
186
90
  const isGlobal = projectRoot === null;
187
91
  const cwd = projectRoot || process.cwd();
92
+ const pkgVersion = getPkgVersion();
188
93
 
189
- // Detect installed agents
190
94
  const detectedAgents = detectAgents();
191
- // Always include claude-code as fallback if nothing detected
192
95
  const targets = detectedAgents.length > 0 ? detectedAgents : ['claude-code'];
193
96
 
194
97
  const results = [];
98
+ const updated = [];
195
99
 
196
100
  for (const agentName of targets) {
197
101
  const agentConfig = AGENT_PATHS.find(a => a.name === agentName);
@@ -203,23 +107,43 @@ function install() {
203
107
 
204
108
  const skillTarget = join(targetPath, SKILL_NAME);
205
109
 
206
- // Skip if already installed with identical content
207
- if (isInstalled(skillTarget, sourceDir)) continue;
110
+ // Update flow: if installed but version changed, re-copy
111
+ if (isInstalled(skillTarget, sourceDir)) {
112
+ // Still write manifest (in case it was missing)
113
+ writeManifest(skillTarget, sourceDir, pkgVersion);
114
+ continue;
115
+ }
116
+
117
+ // Check if existing install needs update
118
+ if (existsSync(skillTarget)) {
119
+ copyDir(sourceDir, skillTarget);
120
+ writeManifest(skillTarget, sourceDir, pkgVersion);
121
+ updated.push(agentName);
122
+ continue;
123
+ }
208
124
 
209
125
  copyDir(sourceDir, skillTarget);
126
+ writeManifest(skillTarget, sourceDir, pkgVersion);
210
127
  results.push(agentName);
211
128
  }
212
129
 
213
- // Report (only in interactive terminals)
214
130
  if (results.length > 0 && isInteractive()) {
215
131
  const noun = results.length === 1 ? 'agent' : 'agents';
216
132
  console.log(
217
133
  `✓ qzhuli-cli skill installed to ${results.length} ${noun}: ${results.join(', ')}`
218
134
  );
219
135
  }
220
- } catch {
221
- // Silent failure never break npm install
222
- // Users can follow AGENT-SETUP.md for manual installation
136
+ if (updated.length > 0 && isInteractive()) {
137
+ const noun = updated.length === 1 ? 'agent' : 'agents';
138
+ console.log(
139
+ `✓ qzhuli-cli skill updated in ${updated.length} ${noun}: ${updated.join(', ')}`
140
+ );
141
+ }
142
+ } catch (err) {
143
+ if (isInteractive()) {
144
+ console.warn('[qzhuli-cli] Skill installation failed:', err?.message || err);
145
+ console.warn('[qzhuli-cli] Manual install: node scripts/install-skill.mjs');
146
+ }
223
147
  }
224
148
  }
225
149
 
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Shared agent detection and skill installation utilities.
3
+ * Used by both postinstall.mjs and install-skill.mjs.
4
+ */
5
+
6
+ import {
7
+ existsSync,
8
+ readdirSync,
9
+ readFileSync,
10
+ writeFileSync,
11
+ copyFileSync,
12
+ mkdirSync,
13
+ rmSync,
14
+ statSync,
15
+ } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { homedir } from 'node:os';
18
+
19
+ // ── Agent path mapping ─────────────────────────────────────────────
20
+ // Source: https://www.agentskills.in/docs/getting-started
21
+ export const AGENT_PATHS = [
22
+ { name: 'claude-code', global: '~/.claude/skills', project: '.claude/skills' },
23
+ { name: 'cursor', global: '~/.cursor/skills', project: '.cursor/skills' },
24
+ { name: 'codex', global: '~/.codex/skills', project: '.codex/skills' },
25
+ { name: 'copilot', global: '~/.copilot/skills', project: '.github/skills' },
26
+ { name: 'windsurf', global: '~/.codeium/windsurf/skills', project: '.windsurf/skills' },
27
+ { name: 'openclaw', global: '~/.openclaw/skills', project: 'skills' },
28
+ { name: 'opencode', global: '~/.config/opencode/skill', project: '.opencode/skill' },
29
+ { name: 'cline', global: '~/.cline/skills', project: '.cline/skills' },
30
+ { name: 'gemini-cli', global: '~/.gemini/skills', project: '.gemini/skills' },
31
+ { name: 'amp', global: '~/.config/agents/skills', project: '.agents/skills' },
32
+ { name: 'roo', global: '~/.roo/skills', project: '.roo/skills' },
33
+ { name: 'goose', global: '~/.config/goose/skills', project: '.goose/skills' },
34
+ { name: 'kiro', global: '~/.kiro/skills', project: '.kiro/skills' },
35
+ { name: 'qwen-code', global: '~/.qwen/skills', project: '.qwen/skills' },
36
+ { name: 'qoder', global: '~/.qoder/skills', project: '.qoder/skills' },
37
+ { name: 'trae', global: '~/.trae/skills', project: '.trae/skills' },
38
+ { name: 'augment', global: '~/.augment/skills', project: '.augment/skills' },
39
+ { name: 'zed', global: '~/.config/zed/skills', project: '.zed/skills' },
40
+ ];
41
+
42
+ export const SKILL_NAME = 'qzhuli-cli';
43
+ export const MANIFEST_FILE = '.qzhuli-manifest.json';
44
+
45
+ // ── Helpers ────────────────────────────────────────────────────────
46
+
47
+ export function resolveHome(p) {
48
+ if (p.startsWith('~/')) return join(homedir(), p.slice(2));
49
+ return p;
50
+ }
51
+
52
+ export function dirExists(p) {
53
+ try {
54
+ return statSync(resolveHome(p)).isDirectory();
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ export function copyDir(src, dest) {
61
+ mkdirSync(dest, { recursive: true });
62
+
63
+ // Remove stale files from dest that no longer exist in source
64
+ const srcNames = new Set(readdirSync(src).map(e => e));
65
+ if (existsSync(dest)) {
66
+ for (const name of readdirSync(dest)) {
67
+ if (!srcNames.has(name)) {
68
+ rmSync(join(dest, name), { recursive: true, force: true });
69
+ }
70
+ }
71
+ }
72
+
73
+ // Copy source to dest
74
+ const entries = readdirSync(src, { withFileTypes: true });
75
+ for (const entry of entries) {
76
+ const srcPath = join(src, entry.name);
77
+ const destPath = join(dest, entry.name);
78
+ if (entry.isDirectory()) {
79
+ copyDir(srcPath, destPath);
80
+ } else if (entry.isSymbolicLink()) {
81
+ continue;
82
+ } else {
83
+ copyFileSync(srcPath, destPath);
84
+ }
85
+ }
86
+ }
87
+
88
+ export function isInstalled(skillTarget, sourceDir) {
89
+ if (!existsSync(skillTarget)) return false;
90
+ try {
91
+ const srcMd = join(sourceDir, 'SKILL.md');
92
+ const destMd = join(skillTarget, 'SKILL.md');
93
+ if (!existsSync(destMd)) return false;
94
+ return readFileSync(srcMd, 'utf8') === readFileSync(destMd, 'utf8');
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ export function detectAgents() {
101
+ return AGENT_PATHS
102
+ .filter(agent => dirExists(agent.global))
103
+ .map(agent => agent.name);
104
+ }
105
+
106
+ // ── Manifest tracking ──────────────────────────────────────────────
107
+
108
+ export function writeManifest(skillTarget, sourceDir, pkgVersion) {
109
+ const manifestPath = join(skillTarget, MANIFEST_FILE);
110
+ const files = readdirSync(sourceDir, { withFileTypes: true })
111
+ .filter(e => !e.isDirectory())
112
+ .map(e => e.name);
113
+ const manifest = {
114
+ source: `@qzhuli/qzhuli-cli@${pkgVersion || 'unknown'}`,
115
+ installed_at: new Date().toISOString(),
116
+ skill_version: readSkillVersion(sourceDir),
117
+ files,
118
+ };
119
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
120
+ }
121
+
122
+ export function readManifest(skillTarget) {
123
+ const manifestPath = join(skillTarget, MANIFEST_FILE);
124
+ if (!existsSync(manifestPath)) return null;
125
+ try {
126
+ return JSON.parse(readFileSync(manifestPath, 'utf8'));
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ export function needsUpdate(skillTarget, sourceDir) {
133
+ const manifest = readManifest(skillTarget);
134
+ if (!manifest) return true;
135
+ const currentVersion = readSkillVersion(sourceDir);
136
+ return manifest.skill_version !== currentVersion;
137
+ }
138
+
139
+ function readSkillVersion(sourceDir) {
140
+ const skillMd = join(sourceDir, 'SKILL.md');
141
+ if (!existsSync(skillMd)) return 'unknown';
142
+ try {
143
+ const content = readFileSync(skillMd, 'utf8');
144
+ const match = content.match(/^version:\s*(.+)$/m);
145
+ return match ? match[1].trim() : 'unknown';
146
+ } catch {
147
+ return 'unknown';
148
+ }
149
+ }
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: qzhuli-cli
3
- description: Use when operating the QZhuli CLI with qz, including login, auth status, config, friends, relations, users, conversations, messages, cache management, JSON filtering, dry-run, command help, and interpreting test-environment banners or config files.
4
- version: 2
3
+ description: Use when operating the QZhuli CLI (`qz`), including login, auth status, config, friends, relations, users, conversations, messages, cache management, JSON filtering, dry-run, command help, and interpreting test-environment banners or config files.
4
+ version: 3
5
5
  ---
6
6
 
7
7
  # QZhuli CLI
8
8
 
9
- Use this skill as a concise operating manual for the `qz` command.
9
+ Concise operating manual for the `qz` command.
10
10
 
11
11
  ## First Checks
12
12
 
@@ -22,60 +22,96 @@ Use this skill as a concise operating manual for the `qz` command.
22
22
  ```bash
23
23
  qz --help
24
24
  qz <command> --help
25
- qz <command> <subcommand> --help
26
25
  ```
27
26
 
28
- ## Environment and Config Files
27
+ ## Safety Rules
29
28
 
30
- If the CLI prints a `DEVELOPMENT BUILD` / `Running in TEST environment` banner, it is using the test environment and
31
- stores config under `./.qzhuli-cli/` relative to the process working directory.
29
+ **These rules prevent data loss and unwanted side effects. Follow them strictly.**
32
30
 
33
- | Visible behavior | Environment | Config directory |
34
- |-------------------|-------------|------------------|
35
- | Shows test banner | test | `./.qzhuli-cli/` |
36
- | No test banner | production | `~/.qzhuli-cli/` |
31
+ ### Ask Before Acting
37
32
 
38
- Treat `credentials.json` as secret. Preferences live in `preferences.json`.
33
+ When ANY of the following applies, STOP and ask the user first:
39
34
 
40
- ## Output and Global Options
35
+ - **Ambiguous search results**: `user search` returns multiple candidates or `status: "needs_resolution"` — show options
36
+ and ask.
37
+ - **Friend operations**: Before `user add`, show the target profile and confirm.
38
+ - **Relation changes**: Before `relation set`, show the current value and the new value, then confirm.
39
+ - **Message sending**: Before `message send`, show the target conversation, recipient, and message content.
40
+ - **Cache clearing**: Before `cache clear`, confirm scope (all tables vs single table).
41
+ - **Any write operation** (`user add`, `relation set`, `message send`, `conversation create`, `cache clear`): confirm
42
+ with user.
41
43
 
42
- Commands return JSON with `status`, `code`, `message`, and `data`. Check `status`, not only the exit code; ambiguous
43
- friend lookups can return `status: "needs_resolution"`.
44
+ ### Use --dry-run for Preview
44
45
 
45
- Use `--jq` for simple dot-path filtering:
46
+ Before any write operation the user hasn't explicitly confirmed, run with `--dry-run` first:
46
47
 
47
48
  ```bash
48
- qz --jq ".data.uid" auth status
49
- qz --jq ".data.links" friend list
50
- qz --jq ".data" conversation list --limit 5 --offset 0
49
+ qz --dry-run message send <id> <cid> "hello"
50
+ qz --dry-run relation set <uid> --remark "New Name"
51
51
  ```
52
52
 
53
- `--jq` is not full jq. Prefer simple paths such as `.data`, `.data.uid`, `.data.links`.
53
+ ### Least-Surprise Principle
54
+
55
+ - Never auto-send messages without explicit content approval.
56
+ - Never change a friend's remark without showing both old and new.
57
+ - Never delete cache data without confirming scope.
58
+ - If the user says "send a message" but doesn't specify content, draft it and ask before sending.
59
+
60
+ ## ID Reference
54
61
 
55
- Use `--dry-run` when you need to avoid side effects. It is wired through output handling, HTTP API calls, IM WebSocket
56
- actions, auth login/logout, and preference writes.
62
+ The CLI uses 5 distinct ID types. **Using the wrong type will fail silently or hit the wrong target.**
57
63
 
58
- ## Cache Architecture
64
+ | ID Type | Field Name | Format | Example | Used By |
65
+ |-----------------|------------------|-------------------------|----------------------------------------|---------------------------------------------------------------------------------------------------------------------|
66
+ | Q助号 | `id` | Number, short | `10003` | `user add <q-number>`, `user search`, `conversation search` (default) |
67
+ | UID | `uid` | 32-char hex string | `d5b6308e3abad6bc96573c58` | `relation get/set`, `friend profile --uid`, `user search --uid`, `conversation search --uid`, `conversation create` |
68
+ | CID | `cid` | UUID | `5c2f46c2-b0d3-405d-ad21-d833538b77f7` | `message send <target-cid>` — **use the OTHER participant's cid, not your own** |
69
+ | Conversation ID | `conversationId` | Base64-like long string | `9boGaR7iii2Jdjhmb5LSo37...` | `message send`, `message history`, `conversation profile` |
70
+ | Agent ID | `agent.id` | Number | `5` | `conversation create --agent-id` |
59
71
 
60
- All read operations use a **Repository Pattern** with SQLite-backed caching:
72
+ **Quick identification by format**:
61
73
 
62
- - **Local-first**: reads hit a local SQLite database (`~/.qzhuli-cli/cache.db`) before the remote API
63
- - **TTL-based expiration**: conversations expire after 30 minutes, contacts/relations after 5 minutes, user profiles after 1 hour
64
- - **Incremental sync**: sync compares the IM conversation list against a local index, only fetching profiles for *new* conversations
65
- - **Auto-sync on miss**: cache miss triggers an incremental sync (not full refetch)
66
- - **Manual sync**: use `qz cache sync` to sync only new data
74
+ - A short integer Q助号
75
+ - A 32-char hex string UID
76
+ - A UUID with dashes CID
77
+ - A long Base64-like string conversationId
67
78
 
68
- Cache database tables:
79
+ **Common mistake**: Using UID for `message send` instead of conversationId. Always resolve via `conversation search` or
80
+ `conversation list` first.
81
+
82
+ ## Environment and Config Files
83
+
84
+ | Visible behavior | Environment | Config directory |
85
+ |----------------------------------------------------------------|-------------|------------------------------------|
86
+ | Shows `DEVELOPMENT BUILD / Running in TEST environment` banner | test | `./.qzhuli-cli/` (relative to CWD) |
87
+ | No test banner | production | `~/.qzhuli-cli/` |
88
+
89
+ Treat `credentials.json` as secret. Preferences live in `preferences.json`.
90
+
91
+ ## Output and Global Options
69
92
 
70
- - `conversations_index` lightweight IM metadata (id, version, members, nicks) for incremental sync
71
- - `conversation_profiles` — full conversation profile data with user_ids index
72
- - `contacts_cache` — full contacts list per owner UID
73
- - `user_profiles` — individual user profiles
74
- - `relations_cache` — friend relations (remark, type)
75
- - `messages_cache` — message history per conversation
93
+ Commands return JSON with `status`, `code`, `message`, and `data`.
76
94
 
77
- Write operations (send message, create conversation, update relation) bypass the cache and write directly to the API,
78
- then invalidate relevant cache entries.
95
+ | status | Meaning | Exit code |
96
+ |----------------------|-------------------------------------|-----------|
97
+ | `"success"` | Operation completed | 0 |
98
+ | `"needs_resolution"` | Ambiguous result (multiple matches) | 0 |
99
+ | `"error"` | Failed operation | 1 |
100
+
101
+ **Always check `status`, not just exit code.**
102
+
103
+ Use `--jq` for simple dot-path filtering:
104
+
105
+ ```bash
106
+ qz --jq ".data.uid" auth status
107
+ qz --jq ".data.links" friend list
108
+ qz --jq ".data" conversation list --limit 5
109
+ ```
110
+
111
+ `--jq` is not full jq. Prefer simple paths: `.data`, `.data.uid`, `.data.links`.
112
+
113
+ Use `--dry-run` to preview without side effects. Wired through: output, HTTP API calls, IM WebSocket actions, auth
114
+ login/logout, and preference writes.
79
115
 
80
116
  ## Command Map
81
117
 
@@ -101,94 +137,54 @@ then invalidate relevant cache entries.
101
137
  | Search user conversations | `qz conversation search <query> [--uid]` |
102
138
  | Send message | `qz message send <conversation-id> <target-cid> <content>` |
103
139
  | Read message history | `qz message history <conversation-id> [--from <id>] [--direction newer\|older] [--limit <n>]` |
104
- | **Sync cache** | `qz cache sync` |
105
- | **Cache status** | `qz cache status` |
106
- | **Clear cache** | `qz cache clear [--table <name>]` |
140
+ | Sync cache | `qz cache sync` |
141
+ | Cache status | `qz cache status` |
142
+ | Clear cache | `qz cache clear [--table <name>]` |
107
143
 
108
- Relation type values are `0=stranger`, `1=friend`, `2=family`, `3=colleague`.
144
+ Relation type values: `0=stranger`, `1=friend`, `2=family`, `3=colleague`.
109
145
 
110
146
  ## Common Workflows
111
147
 
112
148
  ### Find a Friend and Inspect Relation
113
149
 
114
- 1. List candidates:
115
- ```bash
116
- qz friend list
117
- ```
118
- 2. Resolve ambiguous names:
119
- ```bash
120
- qz friend profile "<nickname>"
121
- ```
122
- 3. Use the resolved `uid`:
123
- ```bash
124
- qz relation get <uid>
125
- ```
150
+ 1. `qz friend list` — list candidates
151
+ 2. `qz friend profile "<nickname>"` — resolve ambiguous names
152
+ 3. `qz relation get <uid>` — inspect relation with resolved uid
126
153
 
127
154
  ### Update a Friend Relation
128
155
 
129
- 1. Resolve the exact `uid` first with `friend list` or `friend profile`.
130
- 2. Confirm with the user before changing data.
131
- 3. Execute one or both updates:
132
- ```bash
133
- qz relation set <uid> --remark "Product Manager" --type 1
134
- ```
135
- 4. Verify:
136
- ```bash
137
- qz relation get <uid>
138
- ```
156
+ 1. Resolve the exact `uid` with `friend list` or `friend profile`.
157
+ 2. Show current value to user, confirm the change.
158
+ 3. Execute: `qz relation set <uid> --remark "New Name" --type 1`
159
+ 4. Verify: `qz relation get <uid>`
139
160
 
140
- Do not run `relation set` without `--remark` or `--type`; the CLI returns `INVALID_ARGUMENT`.
161
+ **Do not run `relation set` without `--remark` or `--type`; the CLI returns `INVALID_ARGUMENT`.**
141
162
 
142
163
  ### Search and Add a Friend
143
164
 
144
- 1. Search by Q助号:
145
- ```bash
146
- qz user search 10000
147
- ```
148
- 2. Add directly by Q助号:
149
- ```bash
150
- qz user add 10000
151
- ```
165
+ 1. Search: `qz user search 10000`
166
+ 2. Show profile to user, confirm.
167
+ 3. Add: `qz user add 10000` (internally: search → create conversation)
152
168
 
153
169
  ### Find All Conversations with a User
154
170
 
155
- Search by Q助号 (default):
156
-
157
- ```bash
158
- qz conversation search 10000
159
- ```
160
-
161
- Search by UID:
162
-
163
171
  ```bash
164
- qz conversation search 79345121120f6e5288238749 --uid
172
+ qz conversation search 10000 # by Q助号 (default)
173
+ qz conversation search d5b6308e3abad6bc96573c58 --uid # by UID
165
174
  ```
166
175
 
167
- The response includes `id` (Q助号, number), `uid` (internal user ID, string), and `conversations` with full profile
168
- data. Each conversation entry contains `conversationId`, `isGroup`, `users` (with camelCase fields), and `visitors`.
176
+ Response includes `id` (Q助号), `uid` (internal user ID), and `conversations` with full profile data. Each conversation
177
+ entry contains `conversationId`, `isGroup`, `users`, and `visitors`.
169
178
 
170
179
  ### Send a Message
171
180
 
172
- 1. Confirm auth:
173
- ```bash
174
- qz auth status
175
- ```
176
- 2. Get conversations:
177
- ```bash
178
- qz conversation list --limit 10
179
- ```
180
- 3. Pick the conversation `id`.
181
- 4. Pick `target-cid` from that conversation's `cids`; for one-to-one chats this is usually the other participant's cid,
182
- not the current user's own `cid`.
183
- 5. Confirm recipient and content with the user if there is any ambiguity.
184
- 6. Send:
185
- ```bash
186
- qz message send <conversation-id> <target-cid> "message text"
187
- ```
188
- 7. Verify:
189
- ```bash
190
- qz message history <conversation-id> --limit 5
191
- ```
181
+ 1. Confirm auth: `qz auth status`
182
+ 2. Get conversations: `qz conversation list --limit 10`
183
+ 3. Pick the conversation `id` (conversationId).
184
+ 4. Pick `target-cid` from that conversation's `users` array — **use the OTHER participant's cid**.
185
+ 5. Show recipient name and message content to user, confirm.
186
+ 6. Send: `qz message send <conversation-id> <target-cid> "message text"`
187
+ 7. Verify: `qz message history <conversation-id> --limit 5`
192
188
 
193
189
  ### Page Through Message History
194
190
 
@@ -206,15 +202,29 @@ qz cache status # verify record counts and sync time
206
202
  qz conversation search 10000 # now instant from cache
207
203
  ```
208
204
 
205
+ ## Cache Architecture (Reference)
206
+
207
+ Read operations use a **Repository Pattern** with SQLite-backed caching (`~/.qzhuli-cli/cache.db`):
208
+
209
+ - **TTL**: conversations 30 min, contacts/relations 5 min, user profiles 1 hour
210
+ - **Incremental sync**: only fetches profiles for *new* conversations
211
+ - **Cache miss** → auto incremental sync (not full refetch)
212
+ - **Write operations** bypass cache, invalidate relevant entries
213
+
214
+ Tables: `conversations_index`, `conversation_profiles`, `contacts_cache`, `user_profiles`, `relations_cache`,
215
+ `messages_cache`.
216
+
209
217
  ## Troubleshooting
210
218
 
211
- | Symptom | Action |
212
- |------------------------|--------------------------------------------------------------------------|
213
- | Command not found | Confirm `qz` is installed or available on `PATH`. |
214
- | Auth failure | Run `qz auth status`; then `qz auth login` if needed. |
215
- | Unexpected language | Run `qz config --locale en` or `qz config --locale zh`. |
216
- | Too much JSON | Use `--jq ".data"` or another simple dot path. |
217
- | Need a no-op preview | Use `--dry-run`. |
218
- | Message send cid error | Re-check `auth status` and choose `target-cid` from `conversation list`. |
219
- | Slow queries | Run `qz cache sync` first (incremental, fast), then retry — results come from local SQLite. |
220
- | Cache corrupted | Run `qz cache clear` to reset, then retry (falls back to API). |
219
+ | Symptom | Action |
220
+ |---------------------------------|---------------------------------------------------------------------------------------------------------------|
221
+ | Command not found | Confirm `qz` is on PATH. Install: `npm install -g @qzhuli/qzhuli-cli` |
222
+ | Auth failure | `qz auth status`; then `qz auth login` if needed |
223
+ | Unexpected language | `qz config --locale en` or `--locale zh` |
224
+ | Too much JSON | Use `--jq ".data"` or another simple dot path |
225
+ | Need no-op preview | Use `--dry-run` |
226
+ | Message send cid error | Re-check `auth status`, choose `target-cid` from `conversation list` — it must be the other participant's cid |
227
+ | Slow queries | Run `qz cache sync` first (incremental, fast), then retry |
228
+ | Cache corrupted | `qz cache clear` to reset, then retry (falls back to API) |
229
+ | Ambiguous search | `status: "needs_resolution"` — refine query with `--uid` or `--remark` flag |
230
+ | `relation set` INVALID_ARGUMENT | Must include at least one of `--remark` or `--type` |