cdragon 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -91,6 +91,27 @@ cdragon --global --both tdd grill-me # ~/.claude, ~/.agents 양쪽에 일부
91
91
 
92
92
  이미 존재하는 **실제 디렉터리**는 덮어쓰지 않고 건너뜁니다. 같은 대상을 가리키는 심링크는 그대로 두고, 다른 곳을 가리키면 다시 연결합니다.
93
93
 
94
+ ### 개발
95
+
96
+ 레포 루트에서 한 번 `npm link` 해두면, 이후 코드 수정은 재링크 없이 바로 반영됩니다.
97
+
98
+ ```bash
99
+ npm link # 최초 1회
100
+ ```
101
+
102
+ ### 배포
103
+
104
+ 스킬을 추가했거나 CLI를 수정했다면 버전을 올려 다시 배포합니다. **버전을 올리지 않으면 `npm publish`가 거부됩니다** (같은 버전 재배포 불가).
105
+
106
+ ```bash
107
+ npm version patch # 0.1.0 → 0.1.1 (버그픽스) — 커밋+태그 자동 생성
108
+ # npm version minor # 0.1.0 → 0.2.0 (기능 추가)
109
+ git push --follow-tags # 커밋과 태그를 함께 푸시
110
+ npm publish --access public # 게시 (보안키/OTP 인증)
111
+ ```
112
+
113
+ `npm version`이 `package.json` 버전 변경·커밋·git 태그를 한 번에 처리합니다. 사용자는 `npm i -g cdragon@latest`로 갱신합니다.
114
+
94
115
  ## Getting Started
95
116
 
96
117
  ```bash
package/bin/cdragon.js CHANGED
@@ -8,10 +8,20 @@ const pkg = require('../package.json')
8
8
  const c = require('../src/colors')
9
9
  const prompt = require('../src/prompt')
10
10
  const { SKILLS_DIR, discoverSkills } = require('../src/skills')
11
- const { linkSkills } = require('../src/link')
11
+ const { linkSkills, isInstalledIn } = require('../src/link')
12
12
 
13
13
  const truncate = (s, n) => (s.length > n ? s.slice(0, n - 1) + '…' : s)
14
14
 
15
+ // Split skills into ordered, non-empty groups by where they came from.
16
+ function groupBySource(skills) {
17
+ return [
18
+ { key: 'mine', label: 'My skills' },
19
+ { key: 'installed', label: 'External' },
20
+ ]
21
+ .map((g) => ({ ...g, items: skills.filter((s) => s.source === g.key) }))
22
+ .filter((g) => g.items.length)
23
+ }
24
+
15
25
  function parseArgs(args) {
16
26
  const opts = { scope: null, folders: [], all: false, skills: [], yes: false }
17
27
 
@@ -66,9 +76,12 @@ function listSkills() {
66
76
  return
67
77
  }
68
78
  const width = Math.max(...skills.map((s) => s.name.length))
69
- console.log(`\n${c.bold(`Skills (${skills.length})`)} ${c.dim(SKILLS_DIR)}\n`)
70
- for (const s of skills) {
71
- console.log(` ${c.cyan(s.name.padEnd(width))} ${c.dim(truncate(s.description, 80))}`)
79
+ console.log(`\n${c.bold(`Skills (${skills.length})`)} ${c.dim(SKILLS_DIR)}`)
80
+ for (const group of groupBySource(skills)) {
81
+ console.log(`\n ${c.bold(group.label)} ${c.dim(`(${group.items.length})`)}`)
82
+ for (const s of group.items) {
83
+ console.log(` ${c.cyan(s.name.padEnd(width))} ${c.dim(truncate(s.description, 74))}`)
84
+ }
72
85
  }
73
86
  console.log('')
74
87
  }
@@ -96,6 +109,8 @@ async function linkCommand(opts) {
96
109
  }
97
110
  if (!folders.length) throw new Error('No target folder selected.')
98
111
 
112
+ const base = scope === 'global' ? os.homedir() : process.cwd()
113
+
99
114
  // 3. Skills: all, named, or interactively picked.
100
115
  let chosen
101
116
  if (opts.all) {
@@ -107,16 +122,25 @@ async function linkCommand(opts) {
107
122
  return found
108
123
  })
109
124
  } else {
110
- const picked = await prompt.multiselect(
111
- 'Which skills to link?',
112
- skills.map((s) => ({ label: `${s.name} ${c.dim(truncate(s.description, 56))}`, value: s.name }))
113
- )
125
+ const choices = []
126
+ for (const group of groupBySource(skills)) {
127
+ choices.push({ header: true, label: `${group.label} (${group.items.length})` })
128
+ for (const s of group.items) {
129
+ // Pre-check skills already linked into the chosen target.
130
+ const installed = isInstalledIn(s, base, folders)
131
+ const tag = installed ? c.dim(' (linked)') : ''
132
+ choices.push({
133
+ value: s.name,
134
+ checked: installed,
135
+ label: `${s.name} ${c.dim(truncate(s.description, 56))}${tag}`,
136
+ })
137
+ }
138
+ }
139
+ const picked = await prompt.multiselect('Which skills to link?', choices)
114
140
  chosen = picked.map((name) => skills.find((s) => s.name === name))
115
141
  }
116
142
  if (!chosen.length) throw new Error('No skills selected.')
117
143
 
118
- const base = scope === 'global' ? os.homedir() : process.cwd()
119
-
120
144
  // Summary + confirm.
121
145
  console.log('')
122
146
  console.log(` ${c.bold('scope')} ${scope} ${c.dim(`(${base})`)}`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdragon",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Symlink this repo's agent skills into a project's or your global .claude/skills or .agents/skills",
5
5
  "bin": {
6
6
  "cdragon": "bin/cdragon.js"
@@ -0,0 +1,54 @@
1
+ ---
2
+ name: gws-sheets
3
+ description: "Google Sheets: Read and write spreadsheets."
4
+ metadata:
5
+ version: 0.22.5
6
+ openclaw:
7
+ category: "productivity"
8
+ requires:
9
+ bins:
10
+ - gws
11
+ cliHelp: "gws sheets --help"
12
+ ---
13
+
14
+ # sheets (v4)
15
+
16
+ > **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
17
+
18
+ ```bash
19
+ gws sheets <resource> <method> [flags]
20
+ ```
21
+
22
+ ## Helper Commands
23
+
24
+ | Command | Description |
25
+ |---------|-------------|
26
+ | [`+append`](../gws-sheets-append/SKILL.md) | Append a row to a spreadsheet |
27
+ | [`+read`](../gws-sheets-read/SKILL.md) | Read values from a spreadsheet |
28
+
29
+ ## API Resources
30
+
31
+ ### spreadsheets
32
+
33
+ - `batchUpdate` — Applies one or more updates to the spreadsheet. Each request is validated before being applied. If any request is not valid then the entire request will fail and nothing will be applied. Some requests have replies to give you some information about how they are applied. The replies will mirror the requests. For example, if you applied 4 updates and the 3rd one had a reply, then the response will have 2 empty replies, the actual reply, and another empty reply, in that order.
34
+ - `create` — Creates a spreadsheet, returning the newly created spreadsheet.
35
+ - `get` — Returns the spreadsheet at the given ID. The caller must specify the spreadsheet ID. By default, data within grids is not returned. You can include grid data in one of 2 ways: * Specify a [field mask](https://developers.google.com/workspace/sheets/api/guides/field-masks) listing your desired fields using the `fields` URL parameter in HTTP * Set the includeGridData URL parameter to true.
36
+ - `getByDataFilter` — Returns the spreadsheet at the given ID. The caller must specify the spreadsheet ID. For more information, see [Read, write, and search metadata](https://developers.google.com/workspace/sheets/api/guides/metadata). This method differs from GetSpreadsheet in that it allows selecting which subsets of spreadsheet data to return by specifying a dataFilters parameter. Multiple DataFilters can be specified.
37
+ - `developerMetadata` — Operations on the 'developerMetadata' resource
38
+ - `sheets` — Operations on the 'sheets' resource
39
+ - `values` — Operations on the 'values' resource
40
+
41
+ ## Discovering Commands
42
+
43
+ Before calling any API method, inspect it:
44
+
45
+ ```bash
46
+ # Browse resources and methods
47
+ gws sheets --help
48
+
49
+ # Inspect a method's required params, types, and defaults
50
+ gws schema sheets.<resource>.<method>
51
+ ```
52
+
53
+ Use `gws schema` output to build your `--params` and `--json` flags.
54
+
@@ -0,0 +1,55 @@
1
+ ---
2
+ name: gws-sheets-append
3
+ description: "Google Sheets: Append a row to a spreadsheet."
4
+ metadata:
5
+ version: 0.22.5
6
+ openclaw:
7
+ category: "productivity"
8
+ requires:
9
+ bins:
10
+ - gws
11
+ cliHelp: "gws sheets +append --help"
12
+ ---
13
+
14
+ # sheets +append
15
+
16
+ > **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
17
+
18
+ Append a row to a spreadsheet
19
+
20
+ ## Usage
21
+
22
+ ```bash
23
+ gws sheets +append --spreadsheet <ID>
24
+ ```
25
+
26
+ ## Flags
27
+
28
+ | Flag | Required | Default | Description |
29
+ |------|----------|---------|-------------|
30
+ | `--spreadsheet` | ✓ | — | Spreadsheet ID |
31
+ | `--values` | — | — | Comma-separated values (simple strings) |
32
+ | `--json-values` | — | — | JSON array of rows, e.g. '[["a","b"],["c","d"]]' |
33
+ | `--range` | — | `A1` | Target range in A1 notation (e.g. 'Sheet2!A1') to select a specific tab |
34
+
35
+ ## Examples
36
+
37
+ ```bash
38
+ gws sheets +append --spreadsheet ID --values 'Alice,100,true'
39
+ gws sheets +append --spreadsheet ID --json-values '[["a","b"],["c","d"]]'
40
+ gws sheets +append --spreadsheet ID --range "Sheet2!A1" --values 'Alice,100'
41
+ ```
42
+
43
+ ## Tips
44
+
45
+ - Use --values for simple single-row appends.
46
+ - Use --json-values for bulk multi-row inserts.
47
+ - Use --range to append to a specific sheet tab (default: A1, i.e. first sheet).
48
+
49
+ > [!CAUTION]
50
+ > This is a **write** command — confirm with the user before executing.
51
+
52
+ ## See Also
53
+
54
+ - [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
55
+ - [gws-sheets](../gws-sheets/SKILL.md) — All read and write spreadsheets commands
@@ -0,0 +1,48 @@
1
+ ---
2
+ name: gws-sheets-read
3
+ description: "Google Sheets: Read values from a spreadsheet."
4
+ metadata:
5
+ version: 0.22.5
6
+ openclaw:
7
+ category: "productivity"
8
+ requires:
9
+ bins:
10
+ - gws
11
+ cliHelp: "gws sheets +read --help"
12
+ ---
13
+
14
+ # sheets +read
15
+
16
+ > **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
17
+
18
+ Read values from a spreadsheet
19
+
20
+ ## Usage
21
+
22
+ ```bash
23
+ gws sheets +read --spreadsheet <ID> --range <RANGE>
24
+ ```
25
+
26
+ ## Flags
27
+
28
+ | Flag | Required | Default | Description |
29
+ |------|----------|---------|-------------|
30
+ | `--spreadsheet` | ✓ | — | Spreadsheet ID |
31
+ | `--range` | ✓ | — | Range to read (e.g. 'Sheet1!A1:B2') |
32
+
33
+ ## Examples
34
+
35
+ ```bash
36
+ gws sheets +read --spreadsheet ID --range "Sheet1!A1:D10"
37
+ gws sheets +read --spreadsheet ID --range Sheet1
38
+ ```
39
+
40
+ ## Tips
41
+
42
+ - Read-only — never modifies the spreadsheet.
43
+ - For advanced options, use the raw values.get API.
44
+
45
+ ## See Also
46
+
47
+ - [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
48
+ - [gws-sheets](../gws-sheets/SKILL.md) — All read and write spreadsheets commands
File without changes
package/src/link.js CHANGED
@@ -29,6 +29,23 @@ function linkSkill(sourceDir, linkPath) {
29
29
  return 'exists'
30
30
  }
31
31
 
32
+ // Is `sourceDir` already symlinked at linkPath? (true only for a symlink that
33
+ // resolves to exactly this source — not a real dir, not a stale link.)
34
+ function isLinked(sourceDir, linkPath) {
35
+ try {
36
+ if (!fs.lstatSync(linkPath).isSymbolicLink()) return false
37
+ return path.resolve(path.dirname(linkPath), fs.readlinkSync(linkPath)) === sourceDir
38
+ } catch {
39
+ return false
40
+ }
41
+ }
42
+
43
+ // Is the skill already linked into <base>/<folder>/skills for every folder?
44
+ function isInstalledIn(skill, base, folders) {
45
+ const name = path.basename(skill.dir)
46
+ return folders.every((folder) => isLinked(skill.dir, path.join(base, folder, 'skills', name)))
47
+ }
48
+
32
49
  // Link a set of skills into <base>/<folder>/skills, creating the dir if needed.
33
50
  function linkSkills(skills, base, folder) {
34
51
  const root = path.join(base, folder, 'skills')
@@ -44,4 +61,4 @@ function linkSkills(skills, base, folder) {
44
61
  })
45
62
  }
46
63
 
47
- module.exports = { linkSkill, linkSkills }
64
+ module.exports = { linkSkill, linkSkills, isInstalledIn }
package/src/prompt.js CHANGED
@@ -68,23 +68,33 @@ function select(message, choices) {
68
68
  }
69
69
 
70
70
  // Multi-choice checkbox list. Resolves an array of selected `value`s.
71
+ // A choice with `{ header: true }` renders as a non-selectable group label
72
+ // and is skipped during navigation.
71
73
  function multiselect(message, choices) {
72
74
  requireTTY()
73
75
  return new Promise((resolve) => {
74
- let index = 0
75
- const selected = new Set()
76
+ const selectable = choices.map((c, i) => (c.header ? -1 : i)).filter((i) => i >= 0)
77
+ // Pre-check any choice flagged `checked` (e.g. already installed).
78
+ const selected = new Set(selectable.filter((i) => choices[i].checked))
76
79
  const totalLines = choices.length + 1
80
+ let pos = 0 // index into `selectable`
77
81
  let drawn = false
78
82
 
83
+ const cursor = () => selectable[pos]
84
+
79
85
  const draw = () => {
80
86
  let out = drawn ? `\x1b[${totalLines}A` : ''
81
87
  const hint = c.dim('(↑↓ move · space toggle · a all · enter confirm)')
82
88
  out += `\x1b[2K${c.cyan('?')} ${c.bold(message)} ${hint}\n`
83
89
  choices.forEach((choice, i) => {
84
- const active = i === index
90
+ if (choice.header) {
91
+ out += `\x1b[2K ${c.bold(choice.label)}\n`
92
+ return
93
+ }
94
+ const active = i === cursor()
85
95
  const box = selected.has(i) ? c.green('◉') : '◯'
86
96
  const pointer = active ? c.cyan('❯') : ' '
87
- out += `\x1b[2K${pointer} ${box} ${active ? c.cyan(choice.label) : choice.label}\n`
97
+ out += `\x1b[2K ${pointer} ${box} ${active ? c.cyan(choice.label) : choice.label}\n`
88
98
  })
89
99
  process.stdout.write(out)
90
100
  drawn = true
@@ -93,17 +103,18 @@ function multiselect(message, choices) {
93
103
  const onKey = (str, key) => {
94
104
  if (!key) return
95
105
  if (key.name === 'up' || key.name === 'k') {
96
- index = (index - 1 + choices.length) % choices.length
106
+ pos = (pos - 1 + selectable.length) % selectable.length
97
107
  draw()
98
108
  } else if (key.name === 'down' || key.name === 'j') {
99
- index = (index + 1) % choices.length
109
+ pos = (pos + 1) % selectable.length
100
110
  draw()
101
111
  } else if (key.name === 'space' || str === ' ') {
102
- selected.has(index) ? selected.delete(index) : selected.add(index)
112
+ const i = cursor()
113
+ selected.has(i) ? selected.delete(i) : selected.add(i)
103
114
  draw()
104
115
  } else if (key.name === 'a') {
105
- if (selected.size === choices.length) selected.clear()
106
- else choices.forEach((_, i) => selected.add(i))
116
+ if (selected.size === selectable.length) selected.clear()
117
+ else selectable.forEach((i) => selected.add(i))
107
118
  draw()
108
119
  } else if (key.name === 'return') {
109
120
  teardown(onKey)
package/src/skills.js CHANGED
@@ -7,6 +7,19 @@ const path = require('node:path')
7
7
  // location so `cdragon` finds them no matter where it's invoked.
8
8
  const SKILLS_DIR = path.resolve(__dirname, '..', 'skills')
9
9
 
10
+ // skills-lock.json (managed by the `skills` CLI) is the manifest of skills
11
+ // pulled from external sources. Anything not listed there is authored locally.
12
+ const LOCK_PATH = path.resolve(SKILLS_DIR, '..', 'skills-lock.json')
13
+
14
+ function installedSkillNames() {
15
+ try {
16
+ const lock = JSON.parse(fs.readFileSync(LOCK_PATH, 'utf8'))
17
+ return new Set(Object.keys(lock.skills || {}))
18
+ } catch {
19
+ return new Set()
20
+ }
21
+ }
22
+
10
23
  // Parse the YAML frontmatter of a SKILL.md into a flat key/value map.
11
24
  // Handles plain `key: value` and block scalars (`>`, `|`) used for long
12
25
  // descriptions, collapsing them into a single line.
@@ -55,6 +68,7 @@ function discoverSkills(dir) {
55
68
  return []
56
69
  }
57
70
 
71
+ const installed = installedSkillNames()
58
72
  const skills = []
59
73
  for (const entry of entries) {
60
74
  if (!entry.isDirectory()) continue
@@ -66,6 +80,7 @@ function discoverSkills(dir) {
66
80
  name: entry.name,
67
81
  dir: path.join(dir, entry.name),
68
82
  description: fm.description || '',
83
+ source: installed.has(entry.name) ? 'installed' : 'mine',
69
84
  })
70
85
  }
71
86