@garyr/pt-cli 0.25.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 ADDED
@@ -0,0 +1,112 @@
1
+ # pt - Project Template CLI
2
+
3
+ A CLI tool to record directory structures as templates and initialize new projects from them.
4
+
5
+ ```mermaid
6
+ graph LR
7
+ subgraph Inputs ["Source & Configuration"]
8
+ Existing[Existing Project]
9
+ Config[(Template Config)]
10
+ end
11
+
12
+ Engine[[pt-cli]]
13
+
14
+ subgraph Outputs ["Generated Scaffolding"]
15
+ RSA[Replicated Structure A]
16
+ RSB[Replicated Structure B]
17
+ end
18
+
19
+ %% Flow logic
20
+ Existing -- Learn --> Engine
21
+ Config -- Read/Write --> Engine
22
+ Engine -- Initialize --> RSA
23
+ Engine -- Initialize --> RSB
24
+
25
+ %% Separate the Update logic to avoid crossing lines
26
+ Existing -. Update .-> Engine
27
+
28
+ style Engine fill:#f9f,stroke:#333,stroke-width:2px,color:#000
29
+ ```
30
+
31
+ ## The Pipeline Benefit
32
+
33
+ `pt-cli` is built to reduce boilerplate setup and ensure consistency across your workspaces. In a production pipeline, standardization is key to lowering the friction of cognitive load. `pt` helps by:
34
+
35
+ - **Instantly replicating proven architectures:** Stop recreating folder structures manually. `pt learn` saves the shape of any project.
36
+ - **Automating the setup grind:** With post-config tasks, `pt init` can run commands like `npm install`, `git init`, or setup python virtual environments for you.
37
+ - **Global post-config:** Configure shared tasks (e.g. `git init`, `git lfs install`) once in `~/.pt/config.yaml` and have them apply to every new project automatically.
38
+ - **Agentic automation:** Fully supports headless operation via non-interactive flags and includes a skill for integration with AI agents.
39
+ - **File copying & templating:** Beyond directories, it allows injecting variables into key files (`package.json`, `README.md`, etc.) and automatically ports over executable scripts.
40
+
41
+ ## Features at a Glance
42
+
43
+ - Learn any directory structure and save it as a reusable template
44
+ - Initialize new projects from learned templates
45
+ - Define template variables for dynamic file customization
46
+ - **Automatic Variable Detection:** Scans text files for `{{ var }}` syntax during `learn`/`update`
47
+ - Auto-detect and suggest post-config setup tasks
48
+ - Configure global post-config tasks in `~/.pt/config.yaml` (apply to all projects)
49
+ - Baked-in defaults for common project types (javascript, python, godot, etc.)
50
+ - Share templates or use as an API with JSON export/import
51
+ - Fully supports non-interactive mode (`--yes`, `--vars`) for AI agent automation
52
+
53
+ ## Quick Start
54
+
55
+ ### Installation
56
+
57
+ ```bash
58
+ # Clone this repository
59
+ cd pt-cli
60
+ npm install
61
+ npm run build
62
+
63
+ # Link for global use
64
+ npm link
65
+ ```
66
+
67
+ ### Basic Commands
68
+
69
+ ```bash
70
+ # Learn an existing project structure
71
+ pt learn /path/to/PROJECT
72
+
73
+ # Scaffold a new project from a template
74
+ pt init <template_name> /path/to/NEW_PROJECT
75
+
76
+ # List available templates and configurations
77
+ pt config
78
+
79
+ # Export an existing template as JSON
80
+ pt config my-template --json > my-template.json
81
+
82
+ # Import a template from JSON
83
+ pt add my-new-template --file my-new-template.json
84
+ ```
85
+
86
+ ## Documentation
87
+
88
+ - [Detailed Usage](doc/usage.md) - Learn, Initialize, Update, and Remove commands.
89
+ - [Configuration Guide](doc/configuration.md) - Template variables, post-config tasks, file copying, and more.
90
+ - [Exclusions](doc/exclusions.md) - Learn about default ignored files and how to set custom patterns.
91
+
92
+ ## Development
93
+
94
+ ### Project Structure
95
+
96
+ - `src/index.ts`: Entry point and command registration.
97
+ - `src/commands/`: Individual command handler modules.
98
+ - `src/config.ts`: Configuration loading, saving, and type definitions.
99
+
100
+ ### Technical Notes
101
+
102
+ - **ESM Migration**: The project is now pure ESM. All internal imports must use the `.js` extension.
103
+ - **Development Tooling**: Use `tsx` for running `.ts` files directly (`npm run dev`).
104
+ - **Building**: Use `tsc` to compile to `dist/`.
105
+
106
+ ## Agent Integration
107
+
108
+ `pt-cli` is compatible with AI agents. By utilizing the non-interactive flags (`--yes`, `--vars`, `--name`, `--desc`), agents can autonomously scaffold and learn projects without hanging on interactive terminal prompts.
109
+
110
+ An official agent skill is included in this repository: [`skills/agency-pt-operator/SKILL.md`](skills/agency-pt-operator/SKILL.md).
111
+
112
+ Equipping your agent with this skill allows it to automatically use `pt-cli` to lay down standardized boilerplate and capture new architectures you develop together.
@@ -0,0 +1,37 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+ import { loadConfig, saveConfig } from '../config.js';
5
+ export function addCommand(name, jsonStr, options = {}) {
6
+ const config = loadConfig();
7
+ try {
8
+ let data;
9
+ if (options.file) {
10
+ const filePath = path.resolve(options.file);
11
+ data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
12
+ }
13
+ else if (jsonStr) {
14
+ data = JSON.parse(jsonStr);
15
+ }
16
+ else {
17
+ console.error('Error: Either a JSON string or --file <path> must be provided.');
18
+ process.exit(1);
19
+ }
20
+ if (!config.templates)
21
+ config.templates = {};
22
+ // Basic validation: ensure we aren't accidentally adding a full config object
23
+ if (data && data.templates && typeof data.templates === 'object') {
24
+ console.error(chalk.red('Error: The provided JSON appears to be a full configuration file, not a single template.'));
25
+ console.error(chalk.gray('If you want to import a specific template from it, extract that template object first.'));
26
+ process.exit(1);
27
+ }
28
+ config.templates[name] = data;
29
+ saveConfig(config);
30
+ console.log(chalk.green(`✓ Template "${name}" saved successfully.`));
31
+ }
32
+ catch (e) {
33
+ const error = e;
34
+ console.error(chalk.red(`Failed to parse template JSON: ${error.message}`));
35
+ process.exit(1);
36
+ }
37
+ }
@@ -0,0 +1,96 @@
1
+ import chalk from 'chalk';
2
+ import { loadConfig, getTemplateNames, CONFIG_PATH } from '../config.js';
3
+ export function configCommand(templateName, options = {}) {
4
+ const config = loadConfig();
5
+ if (options.json) {
6
+ if (templateName) {
7
+ if (config.templates && config.templates[templateName]) {
8
+ const output = {
9
+ name: templateName,
10
+ ...config.templates[templateName]
11
+ };
12
+ console.log(JSON.stringify(output, null, 2));
13
+ }
14
+ else {
15
+ console.error(chalk.red(`Error: Template "${templateName}" not found.`));
16
+ process.exit(1);
17
+ }
18
+ }
19
+ else {
20
+ console.log(JSON.stringify(config, null, 2));
21
+ }
22
+ return;
23
+ }
24
+ const names = getTemplateNames(config);
25
+ console.log(chalk.cyan('Config Location:'), CONFIG_PATH);
26
+ console.log(chalk.cyan('\nLearned Templates:'));
27
+ if (names.length === 0) {
28
+ console.log(chalk.gray(' (none)'));
29
+ }
30
+ else {
31
+ for (const name of names) {
32
+ const t = config.templates[name];
33
+ if (!t)
34
+ continue;
35
+ console.log(chalk.white(` - ${name}`), chalk.gray(`(${t.description})`));
36
+ if (t.templateRoot) {
37
+ console.log(chalk.gray(` Source: ${t.templateRoot}`));
38
+ }
39
+ if (t.post_config && t.post_config.length > 0) {
40
+ console.log(chalk.cyan(' Post-config:'));
41
+ for (const task of t.post_config) {
42
+ const cmd = task.command || task.script || '(unknown)';
43
+ const typeFilter = task.type ? ` [type: ${task.type}]` : '';
44
+ console.log(chalk.gray(` - ${cmd}${typeFilter}`));
45
+ }
46
+ }
47
+ if (t.post_copy && t.post_copy.length > 0) {
48
+ console.log(chalk.cyan(' post_copy:'));
49
+ for (const f of t.post_copy) {
50
+ console.log(chalk.gray(` - ${f.src} → ${(f.dest || f.src)}`));
51
+ }
52
+ }
53
+ }
54
+ }
55
+ // Show global ignore patterns
56
+ if (config.ignore && config.ignore.length > 0) {
57
+ console.log(chalk.cyan('\nIgnore Patterns (pt learn):'));
58
+ for (const p of config.ignore) {
59
+ console.log(chalk.gray(` - ${p}`));
60
+ }
61
+ }
62
+ // Show default post-config tasks
63
+ if (config.default_post_config && config.default_post_config.length > 0) {
64
+ console.log(chalk.cyan('\nDefault Post-Config Tasks:'));
65
+ for (const task of config.default_post_config) {
66
+ const cmd = task.command || task.script || '(unknown)';
67
+ const desc = task.description ? ` — ${task.description}` : '';
68
+ const checked = task.checked !== false ? '[default: on]' : '[default: off]';
69
+ const typeFilter = task.type ? ` [type: ${task.type}]` : '';
70
+ console.log(chalk.gray(` - ${cmd}${desc}`));
71
+ console.log(chalk.gray(` ${checked}${typeFilter}`));
72
+ }
73
+ }
74
+ // Show global variables
75
+ if (config.variables && config.variables.length > 0) {
76
+ console.log(chalk.cyan('\nGlobal Variables:'));
77
+ for (const v of config.variables) {
78
+ console.log(chalk.white(` - ${v.name}:`), chalk.gray(v.default || '(no default)'));
79
+ if (v.prompt)
80
+ console.log(chalk.gray(` Prompt: ${v.prompt}`));
81
+ if (v.required)
82
+ console.log(chalk.yellow(` [Required]`));
83
+ }
84
+ }
85
+ console.log(chalk.cyan('\nExample post-config in config.yaml:'));
86
+ console.log(chalk.gray(`
87
+ my_template:
88
+ description: "My standard web project"
89
+ post_config:
90
+ - command: "git init"
91
+ description: "Initialize git repository"
92
+ - command: "npm install"
93
+ description: "Install npm dependencies"
94
+ type: "javascript"
95
+ `));
96
+ }
@@ -0,0 +1,12 @@
1
+ import { loadConfig, saveConfig } from '../config.js';
2
+ export function ignoreCommand(patterns, options = {}) {
3
+ const config = loadConfig();
4
+ if (options.set) {
5
+ config.ignore = patterns ? patterns.split(',').map((s) => s.trim()).filter((s) => s !== '') : [];
6
+ saveConfig(config);
7
+ console.log('Ignore patterns updated:', config.ignore);
8
+ }
9
+ else {
10
+ console.log('Current ignore patterns:', config.ignore || []);
11
+ }
12
+ }
@@ -0,0 +1,294 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import inquirer from 'inquirer';
4
+ import { loadConfig } from '../config.js';
5
+ import chalk from 'chalk';
6
+ import { processCopyFiles } from '../substitute.js';
7
+ import { execSync } from 'child_process';
8
+ export async function init(targetName, destPath, options = {}) {
9
+ const config = loadConfig();
10
+ let typeName = targetName;
11
+ // If no name provided, list templates
12
+ if (!typeName) {
13
+ const names = Object.keys(config.templates);
14
+ if (names.length === 0) {
15
+ console.log(chalk.red("No templates found. Run 'pt learn <path>' first."));
16
+ return;
17
+ }
18
+ if (options.yes) {
19
+ console.error(chalk.red("No project type specified and running in non-interactive mode."));
20
+ process.exit(1);
21
+ }
22
+ const { selected } = await inquirer.prompt({
23
+ type: 'list',
24
+ name: 'selected',
25
+ message: 'Select Project Type:',
26
+ loop: false,
27
+ theme: {
28
+ icon: {
29
+ cursor: chalk.green('[x] ')
30
+ }
31
+ },
32
+ choices: names.map(n => ({ name: n, value: n }))
33
+ });
34
+ typeName = selected;
35
+ }
36
+ const template = config.templates[typeName];
37
+ if (!template) {
38
+ console.error(chalk.red(`Template "${typeName}" not found.`));
39
+ process.exit(1);
40
+ }
41
+ let dest = destPath;
42
+ if (!dest) {
43
+ if (options.yes) {
44
+ console.error(chalk.red("No destination path specified and running in non-interactive mode."));
45
+ process.exit(1);
46
+ }
47
+ const { name } = await inquirer.prompt({
48
+ type: 'input',
49
+ name: 'name',
50
+ message: 'Project path/folder name:'
51
+ });
52
+ dest = name;
53
+ }
54
+ const resolvedDest = path.resolve(dest);
55
+ if (fs.existsSync(resolvedDest) && !options.dryRun) {
56
+ console.error(chalk.red(`Error: Destination "${resolvedDest}" already exists.`));
57
+ process.exit(1);
58
+ }
59
+ if (options.dryRun) {
60
+ console.log(chalk.yellow(`\n[DRY RUN] Initializing project "${template.description}" at: ${resolvedDest}`));
61
+ }
62
+ else {
63
+ console.log(chalk.cyan(`\nInitializing project "${template.description}" at: ${resolvedDest}`));
64
+ }
65
+ // Handle Variables
66
+ let variables = {};
67
+ if (template.variables && template.variables.length > 0) {
68
+ if (options.vars) {
69
+ // Parse --vars "key=val,key2=val2"
70
+ const pairs = options.vars.split(',').map((p) => p.trim());
71
+ for (const pair of pairs) {
72
+ const [k, ...v] = pair.split('=');
73
+ if (k && v.length > 0) {
74
+ variables[k.trim()] = v.join('=').trim();
75
+ }
76
+ }
77
+ }
78
+ if (!options.yes) {
79
+ // Prompt for any missing variables
80
+ for (const v of template.variables) {
81
+ if (!variables[v.name]) {
82
+ const answer = await inquirer.prompt({
83
+ type: 'input',
84
+ name: v.name,
85
+ message: v.prompt || `Enter ${v.name}:`,
86
+ default: v.default || ''
87
+ });
88
+ variables[v.name] = answer[v.name];
89
+ }
90
+ }
91
+ }
92
+ else {
93
+ // Non-interactive mode: check required
94
+ for (const v of template.variables) {
95
+ if (!variables[v.name]) {
96
+ if (v.required) {
97
+ console.error(chalk.red(`Error: Variable "${v.name}" is required but was not provided in non-interactive mode. Use --vars ${v.name}=value`));
98
+ process.exit(1);
99
+ }
100
+ else {
101
+ variables[v.name] = v.default || '';
102
+ }
103
+ }
104
+ }
105
+ }
106
+ }
107
+ // 1. Create structure
108
+ createStructure(resolvedDest, template.folders, options.dryRun);
109
+ // Check if templateRoot exists (if it's defined)
110
+ const templateRootExists = template.templateRoot && fs.existsSync(template.templateRoot);
111
+ if (template.templateRoot && !templateRootExists) {
112
+ console.warn(chalk.yellow(`\nWarning: Template source directory not found: ${template.templateRoot}`));
113
+ console.warn(chalk.gray("Folder structure created, but files/boilerplate will be skipped."));
114
+ }
115
+ // 2. Process copy_files
116
+ if (template.copy_files && templateRootExists) {
117
+ if (options.dryRun)
118
+ console.log(chalk.yellow("[DRY RUN] Processing copy_files..."));
119
+ else
120
+ console.log(chalk.cyan("Processing copy_files..."));
121
+ await processCopyFiles(template.templateRoot, resolvedDest, template, variables, options.dryRun);
122
+ }
123
+ // 3. Process post_copy (executable scripts)
124
+ if (template.post_copy && templateRootExists) {
125
+ if (options.dryRun)
126
+ console.log(chalk.yellow("[DRY RUN] Processing post_copy..."));
127
+ else
128
+ console.log(chalk.cyan("Processing post_copy..."));
129
+ for (const file of template.post_copy) {
130
+ const srcPath = path.join(template.templateRoot, file.src);
131
+ const destPath = path.join(resolvedDest, file.dest || file.src);
132
+ if (fs.existsSync(srcPath)) {
133
+ if (options.dryRun) {
134
+ console.log(chalk.gray(` [DRY RUN] Would copy ${file.src} → ${file.dest || file.src}`));
135
+ const ext = path.extname(file.src);
136
+ if (['.sh', '.py', '.bash', '.bat'].includes(ext)) {
137
+ console.log(chalk.gray(` [DRY RUN] Would chmod +x ${file.dest || file.src}`));
138
+ }
139
+ continue;
140
+ }
141
+ const fileContent = fs.readFileSync(srcPath, 'utf-8');
142
+ const destDir = path.dirname(destPath);
143
+ fs.mkdirSync(destDir, { recursive: true });
144
+ fs.writeFileSync(destPath, fileContent);
145
+ // Auto-chmod for executables
146
+ const ext = path.extname(file.src);
147
+ if (['.sh', '.py', '.bash', '.bat'].includes(ext)) {
148
+ try {
149
+ fs.chmodSync(destPath, 0o755);
150
+ }
151
+ catch (e) {
152
+ // chmod not available (Windows)
153
+ }
154
+ }
155
+ console.log(chalk.green(" ✓ " + (file.dest || file.src)));
156
+ }
157
+ else {
158
+ console.warn(chalk.yellow(" ! " + file.src + " not found, skipping"));
159
+ }
160
+ }
161
+ }
162
+ // Write .info.md
163
+ if (!options.dryRun) {
164
+ const infoContent = `# ${typeName}\n\n${template.description || ''}\n`;
165
+ fs.writeFileSync(path.join(resolvedDest, '.info.md'), infoContent);
166
+ }
167
+ else {
168
+ console.log(chalk.gray(` [DRY RUN] Would create .info.md`));
169
+ }
170
+ // Use template post_config tasks
171
+ const allTasks = template.post_config?.filter(t => !t.type || t.type === typeName) || [];
172
+ if (allTasks.length > 0) {
173
+ // Determine which tasks to include
174
+ let selectedTaskNames = [];
175
+ if (options.skipPostConfig) {
176
+ // Skip entirely
177
+ selectedTaskNames = [];
178
+ }
179
+ else if (options.dryRun) {
180
+ // In dry-run, select all (for display)
181
+ selectedTaskNames = allTasks.map(t => t.command || t.script || '');
182
+ console.log(chalk.yellow(`\n[DRY RUN] Applicable post-config tasks:`));
183
+ for (const t of allTasks) {
184
+ const desc = t.description ? ` (${t.description})` : '';
185
+ console.log(chalk.gray(` [template] - ${t.command || t.script}${desc}`));
186
+ }
187
+ }
188
+ else if (options.yes) {
189
+ // All tasks selected
190
+ selectedTaskNames = allTasks.map(t => t.command || t.script || '');
191
+ }
192
+ else if (allTasks.length === 0) {
193
+ selectedTaskNames = [];
194
+ }
195
+ else {
196
+ // Checkbox prompt
197
+ const choices = [];
198
+ for (const t of allTasks) {
199
+ const cmd = t.command || t.script || '(no command)';
200
+ const desc = t.description ? ` (${t.description})` : '';
201
+ choices.push({
202
+ name: `${cmd}${desc}`,
203
+ value: cmd,
204
+ checked: true
205
+ });
206
+ }
207
+ const response = await inquirer.prompt({
208
+ type: 'checkbox',
209
+ name: 'selected',
210
+ message: 'Select post-config tasks to run:',
211
+ loop: false,
212
+ theme: {
213
+ icon: {
214
+ cursor: chalk.green('[x] ')
215
+ }
216
+ },
217
+ choices
218
+ });
219
+ selectedTaskNames = response.selected || [];
220
+ }
221
+ // Write post_config scripts for selected tasks
222
+ if (selectedTaskNames.length > 0 && !options.dryRun) {
223
+ let bashContent = '#!/bin/bash\n# Auto-generated post_config script\n\n';
224
+ let batContent = '@echo off\n:: Auto-generated post_config script\n\n';
225
+ for (const t of allTasks) {
226
+ // Determine the actual command/script to use
227
+ let cmd = '';
228
+ if (t.command) {
229
+ cmd = t.command;
230
+ }
231
+ else if (t.script) {
232
+ cmd = `./${t.script}`;
233
+ }
234
+ // Match against selected names (use command if available, else script)
235
+ const taskKey = t.command || (t.script ? `./${t.script}` : '');
236
+ if (selectedTaskNames.includes(taskKey)) {
237
+ if (cmd) {
238
+ bashContent += `echo "Running: ${t.description || taskKey}"\n${cmd}\n`;
239
+ batContent += `echo Running: ${t.description || taskKey}\n${cmd}\n`;
240
+ }
241
+ }
242
+ }
243
+ fs.writeFileSync(path.join(resolvedDest, 'post_config.sh'), bashContent);
244
+ try {
245
+ fs.chmodSync(path.join(resolvedDest, 'post_config.sh'), 0o755);
246
+ }
247
+ catch (e) { }
248
+ fs.writeFileSync(path.join(resolvedDest, 'post_config.bat'), batContent);
249
+ // Execute the appropriate script
250
+ console.log(chalk.cyan("\nExecuting post-config tasks..."));
251
+ try {
252
+ const scriptCmd = process.platform === 'win32' ? 'post_config.bat' : './post_config.sh';
253
+ execSync(scriptCmd, {
254
+ cwd: resolvedDest,
255
+ stdio: 'inherit'
256
+ });
257
+ }
258
+ catch (e) {
259
+ console.error(chalk.red("\nError: Some post-config tasks failed. Check the output above."));
260
+ }
261
+ }
262
+ }
263
+ if (options.dryRun) {
264
+ console.log(chalk.yellow(`\n[DRY RUN] Project initialization preview complete.`));
265
+ }
266
+ else {
267
+ console.log(chalk.green(`\n✓ Project created successfully.`));
268
+ }
269
+ }
270
+ function createStructure(dirPath, folders, dryRun = false) {
271
+ for (const folder of folders) {
272
+ const fullDirPath = path.join(dirPath, folder.name);
273
+ if (dryRun) {
274
+ console.log(chalk.gray(` [DRY RUN] Would create directory: ${fullDirPath}`));
275
+ }
276
+ else {
277
+ fs.mkdirSync(fullDirPath, { recursive: true });
278
+ }
279
+ // Create .info.md if content exists
280
+ if (folder.info) {
281
+ const infoPath = path.join(fullDirPath, '.info.md');
282
+ if (dryRun) {
283
+ console.log(chalk.gray(` [DRY RUN] Would create info file: ${infoPath}`));
284
+ }
285
+ else {
286
+ fs.writeFileSync(infoPath, folder.info);
287
+ }
288
+ }
289
+ // Recurse children
290
+ if (folder.children && folder.children.length > 0) {
291
+ createStructure(fullDirPath, folder.children, dryRun);
292
+ }
293
+ }
294
+ }