@joehom/awm-cli 0.0.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.
Files changed (46) hide show
  1. package/README.md +666 -0
  2. package/bin/awm.js +2 -0
  3. package/package.json +25 -0
  4. package/skills/awm-cli/SKILL.md +189 -0
  5. package/src/adapters/jsonAdapter.js +54 -0
  6. package/src/adapters/skillApplier.js +35 -0
  7. package/src/adapters/tomlAdapter.js +49 -0
  8. package/src/commands/doctor.js +100 -0
  9. package/src/commands/init.js +34 -0
  10. package/src/commands/mcp.js +253 -0
  11. package/src/commands/pull.js +168 -0
  12. package/src/commands/setup.js +31 -0
  13. package/src/commands/skill.js +187 -0
  14. package/src/commands/status.js +17 -0
  15. package/src/commands/tool.js +45 -0
  16. package/src/defaults/mcps/fetch.json +6 -0
  17. package/src/defaults/mcps/filesystem.json +6 -0
  18. package/src/defaults/mcps/github.json +9 -0
  19. package/src/defaults/mcps/memory.json +6 -0
  20. package/src/defaults/skills/awm-cli/SKILL.md +189 -0
  21. package/src/defaults/tools/claude-code.json +27 -0
  22. package/src/defaults/tools/codex.json +27 -0
  23. package/src/defaults/tools/copilot-cli.json +18 -0
  24. package/src/defaults/tools/cursor.json +27 -0
  25. package/src/defaults/tools/gemini-cli.json +27 -0
  26. package/src/defaults/tools/github-copilot.json +23 -0
  27. package/src/defaults/tools/windsurf.json +23 -0
  28. package/src/index.js +35 -0
  29. package/src/registry/mcpRegistry.js +68 -0
  30. package/src/registry/paths.js +21 -0
  31. package/src/registry/skillRegistry.js +61 -0
  32. package/src/registry/toolRegistry.js +43 -0
  33. package/src/seed.js +131 -0
  34. package/src/tools/claude-code.json +27 -0
  35. package/src/tools/codex.json +27 -0
  36. package/src/tools/copilot-cli.json +15 -0
  37. package/src/tools/cursor.json +27 -0
  38. package/src/tools/gemini-cli.json +27 -0
  39. package/src/tools/github-copilot.json +23 -0
  40. package/src/tools/windsurf.json +23 -0
  41. package/src/utils/fileUtils.js +76 -0
  42. package/src/utils/logger.js +17 -0
  43. package/src/utils/pathResolver.js +40 -0
  44. package/src/utils/validator.js +68 -0
  45. package/src/workspace/applyWorkspace.js +81 -0
  46. package/src/workspace/workspaceConfig.js +34 -0
package/src/seed.js ADDED
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Seeds the AWM registry with default MCPs and skills.
4
+ * Safe to re-run — skips files that already exist unless --force is passed.
5
+ *
6
+ * Called automatically via npm postinstall.
7
+ * Also called by `awm setup [--force]`.
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { getRegistryRoot } from './utils/pathResolver.js';
13
+
14
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
+ const DEFAULTS_DIR = path.join(__dirname, 'defaults');
16
+
17
+ /**
18
+ * Recursively copy src directory to dest.
19
+ * Uses Node.js built-in fs.cpSync (Node 16.7+).
20
+ */
21
+ function copyDirSync(src, dest, force) {
22
+ if (!fs.existsSync(src)) return;
23
+ if (!force && fs.existsSync(dest)) return; // skip existing
24
+ fs.cpSync(src, dest, { recursive: true, force: true });
25
+ }
26
+
27
+ /**
28
+ * Copy a single file to dest path.
29
+ * Skips if dest exists and force=false.
30
+ */
31
+ function copyFileIfMissing(src, dest, force) {
32
+ if (!force && fs.existsSync(dest)) return false;
33
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
34
+ fs.copyFileSync(src, dest);
35
+ return true;
36
+ }
37
+
38
+ export function runSeed(force = false) {
39
+ const registryRoot = getRegistryRoot();
40
+
41
+ // Ensure all registry dirs exist
42
+ for (const sub of ['mcps', 'skills', 'profiles', 'tools']) {
43
+ fs.mkdirSync(path.join(registryRoot, sub), { recursive: true });
44
+ }
45
+
46
+ const results = { mcps: [], skills: [], tools: [], skipped: [] };
47
+
48
+ // --- Seed MCPs ---
49
+ const defaultMcpsDir = path.join(DEFAULTS_DIR, 'mcps');
50
+ if (fs.existsSync(defaultMcpsDir)) {
51
+ for (const file of fs.readdirSync(defaultMcpsDir)) {
52
+ if (!file.endsWith('.json')) continue;
53
+ const src = path.join(defaultMcpsDir, file);
54
+ const dest = path.join(registryRoot, 'mcps', file);
55
+ const copied = copyFileIfMissing(src, dest, force);
56
+ const id = file.replace(/\.json$/, '');
57
+ if (copied) results.mcps.push(id);
58
+ else results.skipped.push(`mcp:${id}`);
59
+ }
60
+ }
61
+
62
+ // --- Seed Tools ---
63
+ const defaultToolsDir = path.join(DEFAULTS_DIR, 'tools');
64
+ if (fs.existsSync(defaultToolsDir)) {
65
+ for (const file of fs.readdirSync(defaultToolsDir)) {
66
+ if (!file.endsWith('.json')) continue;
67
+ const src = path.join(defaultToolsDir, file);
68
+ const dest = path.join(registryRoot, 'tools', file);
69
+ const copied = copyFileIfMissing(src, dest, force);
70
+ const id = file.replace(/\.json$/, '');
71
+ if (copied) results.tools.push(id);
72
+ else results.skipped.push(`tool:${id}`);
73
+ }
74
+ }
75
+
76
+ // --- Seed Skills ---
77
+ const defaultSkillsDir = path.join(DEFAULTS_DIR, 'skills');
78
+ if (fs.existsSync(defaultSkillsDir)) {
79
+ for (const name of fs.readdirSync(defaultSkillsDir)) {
80
+ const src = path.join(defaultSkillsDir, name);
81
+ if (!fs.statSync(src).isDirectory()) continue;
82
+ const dest = path.join(registryRoot, 'skills', name);
83
+ const existed = fs.existsSync(dest);
84
+ if (!force && existed) {
85
+ results.skipped.push(`skill:${name}`);
86
+ continue;
87
+ }
88
+ fs.cpSync(src, dest, { recursive: true, force: true });
89
+ results.skills.push(name);
90
+ }
91
+ }
92
+
93
+ return results;
94
+ }
95
+
96
+ // Only auto-run when executed directly (postinstall or `node src/seed.js`)
97
+ // NOT when imported as a module by src/commands/setup.js
98
+ const selfPath = fileURLToPath(import.meta.url);
99
+ const isDirectRun = process.argv[1] &&
100
+ path.resolve(process.argv[1]) === path.resolve(selfPath);
101
+
102
+ if (isDirectRun) {
103
+ const force = process.argv.includes('--force');
104
+ try {
105
+ const results = runSeed(force);
106
+
107
+ const seeded = results.mcps.length + results.skills.length + results.tools.length;
108
+ if (results.tools.length > 0) {
109
+ console.log(`[awm] Seeded tools: ${results.tools.join(', ')}`);
110
+ }
111
+ if (results.mcps.length > 0) {
112
+ console.log(`[awm] Seeded MCPs: ${results.mcps.join(', ')}`);
113
+ }
114
+ if (results.skills.length > 0) {
115
+ console.log(`[awm] Seeded skills: ${results.skills.join(', ')}`);
116
+ }
117
+ if (results.skipped.length > 0 && seeded > 0) {
118
+ console.log(`[awm] Skipped (already present): ${results.skipped.join(', ')}`);
119
+ }
120
+ if (seeded === 0) {
121
+ if (results.skipped.length > 0) {
122
+ console.log(`[awm] Registry already seeded. Use --force to overwrite.`);
123
+ } else {
124
+ console.log(`[awm] Registry seeded (nothing to add).`);
125
+ }
126
+ }
127
+ } catch (err) {
128
+ // Don't fail the npm install if seeding fails
129
+ console.warn(`[awm] Warning: could not seed registry: ${err.message}`);
130
+ }
131
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "id": "claude-code",
3
+ "name": "Claude Code",
4
+ "supports": {
5
+ "mcp": true,
6
+ "skills": true
7
+ },
8
+ "mcp": {
9
+ "format": "json",
10
+ "project": {
11
+ "targetFile": ".mcp.json",
12
+ "rootObject": "mcpServers"
13
+ },
14
+ "global": {
15
+ "targetFile": "~/.claude.json",
16
+ "rootObject": "mcpServers"
17
+ }
18
+ },
19
+ "skills": {
20
+ "project": {
21
+ "targetFolder": ".claude/skills"
22
+ },
23
+ "global": {
24
+ "targetFolder": "~/.claude/skills"
25
+ }
26
+ }
27
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "id": "codex",
3
+ "name": "Codex CLI",
4
+ "supports": {
5
+ "mcp": true,
6
+ "skills": true
7
+ },
8
+ "mcp": {
9
+ "format": "toml",
10
+ "project": {
11
+ "targetFile": ".codex/config.toml",
12
+ "rootObject": "mcp_servers"
13
+ },
14
+ "global": {
15
+ "targetFile": "~/.codex/config.toml",
16
+ "rootObject": "mcp_servers"
17
+ }
18
+ },
19
+ "skills": {
20
+ "project": {
21
+ "targetFolder": ".agents/skills"
22
+ },
23
+ "global": {
24
+ "targetFolder": "~/.agents/skills"
25
+ }
26
+ }
27
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "id": "copilot-cli",
3
+ "name": "Copilot CLI",
4
+ "supports": {
5
+ "mcp": true,
6
+ "skills": false
7
+ },
8
+ "mcp": {
9
+ "format": "json",
10
+ "global": {
11
+ "targetFile": "~/.copilot/mcp-config.json",
12
+ "rootObject": "servers"
13
+ }
14
+ }
15
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "id": "cursor",
3
+ "name": "Cursor",
4
+ "supports": {
5
+ "mcp": true,
6
+ "skills": true
7
+ },
8
+ "mcp": {
9
+ "format": "json",
10
+ "project": {
11
+ "targetFile": ".cursor/mcp.json",
12
+ "rootObject": "mcpServers"
13
+ },
14
+ "global": {
15
+ "targetFile": "~/.cursor/mcp.json",
16
+ "rootObject": "mcpServers"
17
+ }
18
+ },
19
+ "skills": {
20
+ "project": {
21
+ "targetFolder": ".cursor/rules"
22
+ },
23
+ "global": {
24
+ "targetFolder": "~/.cursor/rules"
25
+ }
26
+ }
27
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "id": "gemini-cli",
3
+ "name": "Gemini CLI",
4
+ "supports": {
5
+ "mcp": true,
6
+ "skills": true
7
+ },
8
+ "mcp": {
9
+ "format": "json",
10
+ "project": {
11
+ "targetFile": ".gemini/settings.json",
12
+ "rootObject": "mcpServers"
13
+ },
14
+ "global": {
15
+ "targetFile": "~/.gemini/settings.json",
16
+ "rootObject": "mcpServers"
17
+ }
18
+ },
19
+ "skills": {
20
+ "project": {
21
+ "targetFolder": ".gemini/skills"
22
+ },
23
+ "global": {
24
+ "targetFolder": "~/.gemini/skills"
25
+ }
26
+ }
27
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "id": "github-copilot",
3
+ "name": "GitHub Copilot (VS Code)",
4
+ "supports": {
5
+ "mcp": true,
6
+ "skills": true
7
+ },
8
+ "mcp": {
9
+ "format": "json",
10
+ "project": {
11
+ "targetFile": ".vscode/mcp.json",
12
+ "rootObject": "servers"
13
+ }
14
+ },
15
+ "skills": {
16
+ "project": {
17
+ "targetFolder": ".github/skills"
18
+ },
19
+ "global": {
20
+ "targetFolder": "~/.copilot/skills"
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "id": "windsurf",
3
+ "name": "Windsurf",
4
+ "supports": {
5
+ "mcp": true,
6
+ "skills": true
7
+ },
8
+ "mcp": {
9
+ "format": "json",
10
+ "global": {
11
+ "targetFile": "~/.codeium/windsurf/mcp_config.json",
12
+ "rootObject": "mcpServers"
13
+ }
14
+ },
15
+ "skills": {
16
+ "project": {
17
+ "targetFolder": ".windsurf/skills"
18
+ },
19
+ "global": {
20
+ "targetFolder": "~/.codeium/windsurf/skills"
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,76 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+
5
+ // Use dynamic import for fs-extra (ESM)
6
+ let fse;
7
+ async function getFse() {
8
+ if (!fse) fse = (await import('fs-extra')).default;
9
+ return fse;
10
+ }
11
+
12
+ /**
13
+ * Read and parse a JSON file. Returns null if file doesn't exist.
14
+ * @param {string} filePath
15
+ * @returns {any}
16
+ */
17
+ export function readJson(filePath) {
18
+ if (!fs.existsSync(filePath)) return null;
19
+ const raw = fs.readFileSync(filePath, 'utf8');
20
+ return JSON.parse(raw);
21
+ }
22
+
23
+ /**
24
+ * Write an object as JSON to a file, creating parent directories as needed.
25
+ * @param {string} filePath
26
+ * @param {any} data
27
+ */
28
+ export function writeJson(filePath, data) {
29
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
30
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
31
+ }
32
+
33
+ /**
34
+ * Copy a directory recursively using fs-extra.
35
+ * @param {string} src
36
+ * @param {string} dest
37
+ */
38
+ export async function copyDir(src, dest) {
39
+ const fsExtra = await getFse();
40
+ await fsExtra.copy(src, dest, { overwrite: true });
41
+ }
42
+
43
+ /**
44
+ * List immediate subdirectory names within a directory.
45
+ * Returns [] if the directory doesn't exist.
46
+ * @param {string} dirPath
47
+ * @returns {string[]}
48
+ */
49
+ export function listDirs(dirPath) {
50
+ if (!fs.existsSync(dirPath)) return [];
51
+ return fs.readdirSync(dirPath, { withFileTypes: true })
52
+ .filter(d => d.isDirectory())
53
+ .map(d => d.name);
54
+ }
55
+
56
+ /**
57
+ * List immediate file names within a directory.
58
+ * Returns [] if the directory doesn't exist.
59
+ * @param {string} dirPath
60
+ * @returns {string[]}
61
+ */
62
+ export function listFiles(dirPath) {
63
+ if (!fs.existsSync(dirPath)) return [];
64
+ return fs.readdirSync(dirPath, { withFileTypes: true })
65
+ .filter(d => d.isFile())
66
+ .map(d => d.name);
67
+ }
68
+
69
+ /**
70
+ * Check if a file or directory exists.
71
+ * @param {string} p
72
+ * @returns {boolean}
73
+ */
74
+ export function fileExists(p) {
75
+ return fs.existsSync(p);
76
+ }
@@ -0,0 +1,17 @@
1
+ export const log = {
2
+ info(msg) {
3
+ console.log(msg);
4
+ },
5
+ success(msg) {
6
+ console.log(`[ok] ${msg}`);
7
+ },
8
+ warn(msg) {
9
+ console.warn(`[warn] ${msg}`);
10
+ },
11
+ error(msg) {
12
+ console.error(`[error] ${msg}`);
13
+ },
14
+ dryRun(msg) {
15
+ console.log(`[dry-run] ${msg}`);
16
+ },
17
+ };
@@ -0,0 +1,40 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Expands ~, %VAR% (Windows), ${VAR}, and ${env:VAR} in a path string,
6
+ * then normalizes the result.
7
+ * @param {string} p
8
+ * @returns {string}
9
+ */
10
+ export function resolvePath(p) {
11
+ if (typeof p !== 'string') return p;
12
+
13
+ let result = p;
14
+
15
+ // Expand ~ to home directory
16
+ if (result.startsWith('~/') || result === '~') {
17
+ result = path.join(os.homedir(), result.slice(1));
18
+ }
19
+
20
+ // Expand ${env:VAR} — must come before ${VAR} to avoid partial matches
21
+ result = result.replace(/\$\{env:([^}]+)\}/g, (_, name) => process.env[name] ?? '');
22
+
23
+ // Expand ${VAR}
24
+ result = result.replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] ?? '');
25
+
26
+ // Expand %VAR% (Windows style)
27
+ result = result.replace(/%([^%]+)%/g, (_, name) => process.env[name] ?? '');
28
+
29
+ return path.normalize(result);
30
+ }
31
+
32
+ /**
33
+ * Returns the registry root directory.
34
+ * Respects AWM_REGISTRY env var override.
35
+ * @returns {string}
36
+ */
37
+ export function getRegistryRoot() {
38
+ return process.env.AWM_REGISTRY
39
+ ?? path.join(os.homedir(), '.agent-workspace', 'registry');
40
+ }
@@ -0,0 +1,68 @@
1
+ const VALID_TRANSPORTS = ['stdio', 'sse', 'http'];
2
+ const VALID_SCOPES = ['project', 'global'];
3
+
4
+ /**
5
+ * Validate an MCP server definition.
6
+ * Returns { valid: boolean, errors: string[] }
7
+ * @param {any} mcp
8
+ */
9
+ export function validateMcp(mcp) {
10
+ const errors = [];
11
+ if (!mcp || typeof mcp !== 'object') {
12
+ return { valid: false, errors: ['MCP definition must be an object'] };
13
+ }
14
+ if (!mcp.id || typeof mcp.id !== 'string') {
15
+ errors.push('MCP requires a string "id" field');
16
+ }
17
+ if (!mcp.transport || !VALID_TRANSPORTS.includes(mcp.transport)) {
18
+ errors.push(`MCP requires "transport" to be one of: ${VALID_TRANSPORTS.join(', ')}`);
19
+ }
20
+ if (!mcp.command || typeof mcp.command !== 'string') {
21
+ errors.push('MCP requires a string "command" field');
22
+ }
23
+ return { valid: errors.length === 0, errors };
24
+ }
25
+
26
+ /**
27
+ * Validate a profile definition.
28
+ * Returns { valid: boolean, errors: string[] }
29
+ * @param {any} profile
30
+ */
31
+ export function validateProfile(profile) {
32
+ const errors = [];
33
+ if (!profile || typeof profile !== 'object') {
34
+ return { valid: false, errors: ['Profile definition must be an object'] };
35
+ }
36
+ if (!profile.id || typeof profile.id !== 'string') {
37
+ errors.push('Profile requires a string "id" field');
38
+ }
39
+ if (!profile.tool || typeof profile.tool !== 'string') {
40
+ errors.push('Profile requires a string "tool" field');
41
+ }
42
+ if (!profile.scope || !VALID_SCOPES.includes(profile.scope)) {
43
+ errors.push(`Profile requires "scope" to be one of: ${VALID_SCOPES.join(', ')}`);
44
+ }
45
+ return { valid: errors.length === 0, errors };
46
+ }
47
+
48
+ /**
49
+ * Validate a tool definition.
50
+ * Returns { valid: boolean, errors: string[] }
51
+ * @param {any} tool
52
+ */
53
+ export function validateToolDef(tool) {
54
+ const errors = [];
55
+ if (!tool || typeof tool !== 'object') {
56
+ return { valid: false, errors: ['Tool definition must be an object'] };
57
+ }
58
+ if (!tool.id || typeof tool.id !== 'string') {
59
+ errors.push('Tool definition requires a string "id" field');
60
+ }
61
+ if (!tool.name || typeof tool.name !== 'string') {
62
+ errors.push('Tool definition requires a string "name" field');
63
+ }
64
+ if (!tool.supports || typeof tool.supports !== 'object') {
65
+ errors.push('Tool definition requires a "supports" object');
66
+ }
67
+ return { valid: errors.length === 0, errors };
68
+ }
@@ -0,0 +1,81 @@
1
+ import path from 'node:path';
2
+ import { log } from '../utils/logger.js';
3
+ import { getMcp } from '../registry/mcpRegistry.js';
4
+ import { listSkills } from '../registry/skillRegistry.js';
5
+ import { loadTool } from '../registry/toolRegistry.js';
6
+ import { applyMcpJson } from '../adapters/jsonAdapter.js';
7
+ import { applyMcpToml } from '../adapters/tomlAdapter.js';
8
+ import { applySkills } from '../adapters/skillApplier.js';
9
+ import { resolvePath } from '../utils/pathResolver.js';
10
+
11
+ /**
12
+ * Apply all MCPs and skills from a workspace config to every listed tool (project scope).
13
+ * @param {object} workspace
14
+ * @param {boolean} dryRun
15
+ */
16
+ export async function applyAll(workspace, dryRun = false) {
17
+ const { tools = [], mcps: mcpIds = [], skills: skillNames = [] } = workspace;
18
+
19
+ // Validate all MCP refs first — collect all errors, abort before any writes
20
+ const errors = [];
21
+ const mcpDefs = [];
22
+ for (const id of mcpIds) {
23
+ const def = getMcp(id);
24
+ if (!def) {
25
+ errors.push(`MCP "${id}" not found in registry`);
26
+ } else {
27
+ mcpDefs.push(def);
28
+ }
29
+ }
30
+
31
+ const registeredSkills = listSkills();
32
+ for (const name of skillNames) {
33
+ if (!registeredSkills.includes(name)) {
34
+ errors.push(`Skill "${name}" not found in registry`);
35
+ }
36
+ }
37
+
38
+ if (errors.length > 0) {
39
+ for (const e of errors) log.error(e);
40
+ throw new Error('Aborting apply due to missing registry entries');
41
+ }
42
+
43
+ // Apply to each tool (project scope only)
44
+ for (const toolId of tools) {
45
+ const tool = loadTool(toolId);
46
+ if (!tool) {
47
+ log.warn(`Tool "${toolId}" not found — skipping`);
48
+ continue;
49
+ }
50
+
51
+ const mcpConfig = tool.mcp?.project;
52
+ if (!mcpConfig) {
53
+ log.warn(`Tool "${toolId}" has no project MCP config — skipping MCPs`);
54
+ } else {
55
+ const targetFile = path.join(process.cwd(), mcpConfig.targetFile);
56
+ const format = tool.mcp.format;
57
+ if (format === 'toml') {
58
+ await applyMcpToml(targetFile, mcpConfig.rootObject, mcpDefs, dryRun);
59
+ } else {
60
+ await applyMcpJson(targetFile, mcpConfig.rootObject, mcpDefs, dryRun);
61
+ }
62
+ }
63
+
64
+ // Apply skills if tool supports them
65
+ if (tool.supports?.skills && skillNames.length > 0) {
66
+ const skillsConfig = tool.skills?.project;
67
+ if (!skillsConfig) {
68
+ log.warn(`Tool "${toolId}" has no project skills config — skipping skills`);
69
+ } else {
70
+ const targetFolder = path.join(process.cwd(), skillsConfig.targetFolder);
71
+ await applySkills(skillNames, targetFolder, dryRun);
72
+ }
73
+ }
74
+ }
75
+
76
+ if (!dryRun && (mcpDefs.length > 0 || skillNames.length > 0)) {
77
+ log.success(`Applied to tools: ${tools.join(', ')}`);
78
+ if (mcpDefs.length > 0) log.info(` MCPs: ${mcpDefs.map(m => m.id).join(', ')}`);
79
+ if (skillNames.length > 0) log.info(` Skills: ${skillNames.join(', ')}`);
80
+ }
81
+ }
@@ -0,0 +1,34 @@
1
+ import path from 'node:path';
2
+ import { readJson, writeJson } from '../utils/fileUtils.js';
3
+
4
+ export function getWorkspacePath() {
5
+ return path.join(process.cwd(), '.awm.json');
6
+ }
7
+
8
+ /**
9
+ * Read .awm.json from cwd. Returns null if not found.
10
+ * @returns {object|null}
11
+ */
12
+ export function readWorkspace() {
13
+ return readJson(getWorkspacePath());
14
+ }
15
+
16
+ /**
17
+ * Write .awm.json to cwd, updating lastSync timestamp.
18
+ * @param {object} ws
19
+ */
20
+ export function writeWorkspace(ws) {
21
+ writeJson(getWorkspacePath(), { ...ws, lastSync: new Date().toISOString() });
22
+ }
23
+
24
+ /**
25
+ * Read .awm.json or throw a helpful error if missing.
26
+ * @returns {object}
27
+ */
28
+ export function requireWorkspace() {
29
+ const ws = readWorkspace();
30
+ if (!ws) {
31
+ throw new Error('No .awm.json found in current directory. Run "awm init" first.');
32
+ }
33
+ return ws;
34
+ }