Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 74 additions & 7 deletions src/commands/setpath.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,90 @@
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
import { SlashCommandBuilder, ChatInputCommandInteraction, AutocompleteInteraction } from 'discord.js';
import { readdirSync, existsSync } from 'node:fs';
import { join, basename } from 'node:path';
import { homedir } from 'node:os';
import * as dataStore from '../services/dataStore.js';

// Scan ~/Projects (one level deep) for git repositories.
// Returns a list of absolute paths, or [] if the dir is missing/unreadable.
function scanGitRepos(): string[] {
const base = join(homedir(), 'Projects');
try {
return readdirSync(base, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => join(base, e.name))
.filter(p => existsSync(join(p, '.git')));
} catch {
return [];
}
}

// Expand a leading ~ to the user's home directory.
// Discord passes paths as raw strings (no shell), so the bot must do this itself.
function expandTilde(p: string): string {
if (!p) return p;
if (p === '~') return homedir();
if (p.startsWith('~/')) return join(homedir(), p.slice(2));
return p;
}

export const setpath = {
data: new SlashCommandBuilder()
.setName('setpath')
.setDescription('Register a project path')
.addStringOption(option =>
option.setName('alias')
.setDescription('Project alias')
.setRequired(true))
.setRequired(true)
.setAutocomplete(true))
.addStringOption(option =>
option.setName('path')
.setDescription('Project path')
.setRequired(true)),

.setDescription('Project path (autocomplete from ~/Projects)')
.setRequired(true)
.setAutocomplete(true)),

async autocomplete(interaction: AutocompleteInteraction) {
const focused = interaction.options.getFocused(true);
const query = (focused.value || '').toLowerCase();
const home = homedir();

if (focused.name === 'path') {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] This autocomplete path now exposes local project metadata before the allowlist check runs. handleInteraction processes autocomplete interactions before isAuthorized, so an unauthorized user in a shared Discord server can type /setpath and receive scanned ~/Projects repo names/paths. Please gate autocomplete interactions with the same allowlist check and return an empty suggestion list for unauthorized users.

const repos = scanGitRepos();
const matches = repos
.filter(p => p.toLowerCase().includes(query))
.slice(0, 25)
.map(p => ({
name: p.replace(home, '~').slice(0, 100),
value: p
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] The displayed choice name is capped at 100 characters, but the autocomplete choice value is still the full absolute path. Discord also limits string choice values to 100 characters, so a long repo path can cause interaction.respond(matches) to reject and return no suggestions. Since execution now expands ~, consider using a tilde-relative value when possible and filtering out values that still exceed the limit.

}));
await interaction.respond(matches);
return;
}

if (focused.name === 'alias') {
// Suggest existing aliases (in case the user is overwriting)
// and aliases derived from repo dir basenames (with `client-` stripped).
const suggestions = new Set<string>();
dataStore.getProjects().forEach(p => suggestions.add(p.alias));
scanGitRepos().forEach(p => {
const a = basename(p).toLowerCase().replace(/^client-/, '');
suggestions.add(a);
});
const matches = [...suggestions]
.filter(a => a.toLowerCase().includes(query))
.slice(0, 25)
.map(a => ({ name: a.slice(0, 100), value: a }));
await interaction.respond(matches);
return;
}

await interaction.respond([]);
},

async execute(interaction: ChatInputCommandInteraction) {
const alias = interaction.options.getString('alias', true);
const path = interaction.options.getString('path', true);

const rawPath = interaction.options.getString('path', true);
const path = expandTilde(rawPath);

dataStore.addProject(alias, path);
await interaction.reply(`✅ Project '${alias}' registered: ${path}`);
}
Expand Down
34 changes: 29 additions & 5 deletions src/commands/use.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { SlashCommandBuilder, ChatInputCommandInteraction, MessageFlags } from 'discord.js';
import { SlashCommandBuilder, ChatInputCommandInteraction, AutocompleteInteraction, MessageFlags } from 'discord.js';
import { homedir } from 'node:os';
import * as dataStore from '../services/dataStore.js';

export const use = {
Expand All @@ -8,12 +9,35 @@ export const use = {
.addStringOption(option =>
option.setName('alias')
.setDescription('Project alias')
.setRequired(true)),

.setRequired(true)
.setAutocomplete(true)),

async autocomplete(interaction: AutocompleteInteraction) {
const focused = interaction.options.getFocused(true);
if (focused.name !== 'alias') return;

const projects = dataStore.getProjects();
const home = homedir();
const query = (focused.value || '').toLowerCase();

const matches = projects
.filter(p =>
p.alias.toLowerCase().includes(query) ||
p.path.toLowerCase().includes(query)
)
.slice(0, 25)
.map(p => ({
name: `${p.alias} → ${p.path.replace(home, '~')}`.slice(0, 100),
value: p.alias
}));

await interaction.respond(matches);
},

async execute(interaction: ChatInputCommandInteraction) {
const alias = interaction.options.getString('alias', true);
const channelId = interaction.channelId;

const project = dataStore.getProject(alias);
if (!project) {
await interaction.reply({
Expand All @@ -22,7 +46,7 @@ export const use = {
});
return;
}

dataStore.setChannelBinding(channelId, alias);
await interaction.reply(`✅ Using project '${alias}' in this channel`);
}
Expand Down